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

601 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-03 13:25 +0000

1from datetime import date, timedelta 

2from unittest.mock import patch 

3 

4import grpc 

5import pytest 

6from sqlalchemy import select 

7 

8from couchers.constants import HOST_REQUEST_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_request_text(text: str = "Offer to host") -> str: 

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

34 if utf16_length >= HOST_REQUEST_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 + "_" * (HOST_REQUEST_MIN_LENGTH_UTF16 - utf16_length) 

37 

38 

39@pytest.fixture(autouse=True) 

40def _(testconfig): 

41 pass 

42 

43 

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

45VALID_DESCRIPTION = ( 

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

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

48) 

49 

50 

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

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

53 with session_scope() as session: 

54 node = Node( 

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

56 node_type=node_type, 

57 ) 

58 session.add(node) 

59 session.flush() 

60 cluster = Cluster( 

61 name="Test community", 

62 description="Test", 

63 parent_node_id=node.id, 

64 is_official_cluster=True, 

65 small_community_features_enabled=small_community_features_enabled, 

66 ) 

67 session.add(cluster) 

68 session.flush() 

69 return node.id 

70 

71 

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

73 with session_scope() as session: 

74 cluster_id = session.execute( 

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

76 ).scalar_one() 

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

78 

79 

80def _create_trip_directly( 

81 user_id: int, 

82 node_id: int, 

83 from_date, 

84 to_date, 

85 *, 

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

87 status=None, 

88 same_gender_only: bool = False, 

89 visibility: ModerationVisibility = ModerationVisibility.visible, 

90) -> int: 

91 with session_scope() as session: 

92 moderation_state = ModerationState( 

93 object_type=ModerationObjectType.public_trip, 

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

95 visibility=visibility, 

96 ) 

97 session.add(moderation_state) 

98 session.flush() 

99 trip = PublicTrip( 

100 user_id=user_id, 

101 node_id=node_id, 

102 from_date=from_date, 

103 to_date=to_date, 

104 description=description, 

105 status=status or PublicTripStatus.searching_for_host, 

106 same_gender_only=same_gender_only, 

107 moderation_state_id=moderation_state.id, 

108 ) 

109 session.add(trip) 

110 session.flush() 

111 moderation_state.object_id = trip.id 

112 return trip.id 

113 

114 

115def test_create_public_trip(db): 

116 user, token = generate_user() 

117 node_id = _make_node() 

118 

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

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

121 

122 with public_trips_session(token) as api: 

123 res = api.CreatePublicTrip( 

124 public_trips_pb2.CreatePublicTripReq( 

125 community_id=node_id, 

126 from_date=from_date.isoformat(), 

127 to_date=to_date.isoformat(), 

128 description=VALID_DESCRIPTION, 

129 ) 

130 ) 

131 

132 assert res.trip_id > 0 

133 assert res.user.user_id == user.id 

134 assert res.community_id == node_id 

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

136 assert res.community_name == "Test community" 

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

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

139 assert res.description == VALID_DESCRIPTION 

140 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

141 

142 with session_scope() as session: 

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

144 assert trip.user_id == user.id 

145 assert trip.node_id == node_id 

146 assert trip.status == PublicTripStatus.searching_for_host 

147 

148 

149def test_create_public_trip_incomplete_profile(db): 

150 _, token = generate_user(complete_profile=False) 

151 node_id = _make_node() 

152 

153 with public_trips_session(token) as api: 

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

155 api.CreatePublicTrip( 

156 public_trips_pb2.CreatePublicTripReq( 

157 community_id=node_id, 

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

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

160 description="Visiting town!", 

161 ) 

162 ) 

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

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

165 

166 

167def test_create_public_trip_community_not_found(db): 

168 _, token = generate_user() 

169 

170 with public_trips_session(token) as api: 

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

172 api.CreatePublicTrip( 

173 public_trips_pb2.CreatePublicTripReq( 

174 community_id=999999, 

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

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

177 description="Visiting town!", 

178 ) 

179 ) 

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

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

182 

183 

184def test_create_public_trip_not_enabled(db): 

185 _, token = generate_user() 

186 node_id = _make_node(small_community_features_enabled=False) 

187 

188 with public_trips_session(token) as api: 

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

190 api.CreatePublicTrip( 

191 public_trips_pb2.CreatePublicTripReq( 

192 community_id=node_id, 

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

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

195 description="Visiting town!", 

196 ) 

197 ) 

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

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

200 

201 

202@pytest.mark.parametrize( 

203 "node_type", 

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

205) 

206def test_create_public_trip_allows_region_and_narrower(db, node_type): 

207 _, token = generate_user() 

208 node_id = _make_node(node_type=node_type) 

209 

210 with public_trips_session(token) as api: 

211 res = api.CreatePublicTrip( 

212 public_trips_pb2.CreatePublicTripReq( 

213 community_id=node_id, 

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

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

216 description=VALID_DESCRIPTION, 

217 ) 

218 ) 

219 assert res.trip_id > 0 

220 

221 

222def test_create_public_trip_in_past_uses_node_timezone(db): 

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

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

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

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

227 _, token = generate_user() 

228 node_id = _make_node() 

229 

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

231 

232 with patch( 

233 "couchers.servicers.public_trips.today_in_timezone", 

234 side_effect=lambda tz: fake_today_by_tz[tz], 

235 ): 

236 with public_trips_session(token) as api: 

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

238 api.CreatePublicTrip( 

239 public_trips_pb2.CreatePublicTripReq( 

240 community_id=node_id, 

241 from_date="2026-01-15", 

242 to_date="2026-01-20", 

243 description=VALID_DESCRIPTION, 

244 ) 

245 ) 

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

247 

248 

249def test_create_public_trip_date_errors(db): 

250 _, token = generate_user() 

251 node_id = _make_node() 

252 

253 with public_trips_session(token) as api: 

254 # from_date in the past 

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=2)).isoformat(), 

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

261 description="Visiting town!", 

262 ) 

263 ) 

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

265 

266 # from_date after to_date 

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

268 api.CreatePublicTrip( 

269 public_trips_pb2.CreatePublicTripReq( 

270 community_id=node_id, 

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

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

273 description="Visiting town!", 

274 ) 

275 ) 

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

277 

278 # empty description 

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

280 api.CreatePublicTrip( 

281 public_trips_pb2.CreatePublicTripReq( 

282 community_id=node_id, 

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

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

285 description=" ", 

286 ) 

287 ) 

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

289 

290 # invalid date format 

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

292 api.CreatePublicTrip( 

293 public_trips_pb2.CreatePublicTripReq( 

294 community_id=node_id, 

295 from_date="not-a-date", 

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

297 description="Visiting town!", 

298 ) 

299 ) 

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

301 

302 

303def test_create_public_trip_overlap(db): 

304 user, token = generate_user() 

305 node_id = _make_node() 

306 

307 _create_trip_directly( 

308 user.id, 

309 node_id, 

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

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

312 ) 

313 

314 with public_trips_session(token) as api: 

315 # overlapping dates should fail 

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

317 api.CreatePublicTrip( 

318 public_trips_pb2.CreatePublicTripReq( 

319 community_id=node_id, 

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

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

322 description=VALID_DESCRIPTION, 

323 ) 

324 ) 

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

326 

327 # non-overlapping dates should succeed 

328 res = api.CreatePublicTrip( 

329 public_trips_pb2.CreatePublicTripReq( 

330 community_id=node_id, 

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

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

333 description=VALID_DESCRIPTION, 

334 ) 

335 ) 

336 assert res.trip_id > 0 

337 

338 

339def test_create_public_trip_closed_trip_allows_new_overlap(db): 

340 user, token = generate_user() 

341 node_id = _make_node() 

342 

343 _create_trip_directly( 

344 user.id, 

345 node_id, 

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

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

348 status=PublicTripStatus.closed, 

349 ) 

350 

351 with public_trips_session(token) as api: 

352 # closed trips shouldn't block new overlapping ones 

353 res = api.CreatePublicTrip( 

354 public_trips_pb2.CreatePublicTripReq( 

355 community_id=node_id, 

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

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

358 description=VALID_DESCRIPTION, 

359 ) 

360 ) 

361 assert res.trip_id > 0 

362 

363 

364def test_get_public_trip(db): 

365 user, token = generate_user() 

366 node_id = _make_node() 

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

368 

369 with public_trips_session(token) as api: 

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

371 assert res.trip_id == trip_id 

372 assert res.user.user_id == user.id 

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

374 assert res.community_name == "Test community" 

375 

376 

377def test_get_public_trip_not_found(db): 

378 _, token = generate_user() 

379 with public_trips_session(token) as api: 

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

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

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

383 

384 

385def test_list_public_trips(db): 

386 traveler, _ = generate_user() 

387 _, host_token = generate_user() 

388 node_id = _make_node() 

389 

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

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

392 

393 with public_trips_session(host_token) as api: 

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

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

396 assert returned_ids == {trip1, trip2} 

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

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

399 

400 

401def test_list_public_trips_filters_closed_and_past(db): 

402 traveler, _ = generate_user() 

403 _, host_token = generate_user() 

404 node_id = _make_node() 

405 

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

407 # closed trip - should be hidden 

408 _create_trip_directly( 

409 traveler.id, 

410 node_id, 

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

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

413 status=PublicTripStatus.closed, 

414 ) 

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

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

417 

418 with public_trips_session(host_token) as api: 

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

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

421 

422 

423def test_list_public_trips_hides_invisible_user(db): 

424 traveler, _ = generate_user() 

425 _, host_token = generate_user() 

426 node_id = _make_node() 

427 

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

429 

430 # soft-delete the traveler 

431 with session_scope() as session: 

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

433 t.deleted_at = now() 

434 

435 with public_trips_session(host_token) as api: 

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

437 assert len(res.public_trips) == 0 

438 

439 

440def test_list_public_trips_pagination(db): 

441 traveler, _ = generate_user() 

442 _, host_token = generate_user() 

443 node_id = _make_node() 

444 

445 trip_ids = [ 

446 _create_trip_directly( 

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

448 ) 

449 for i in range(5) 

450 ] 

451 

452 with public_trips_session(host_token) as api: 

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

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

455 assert res.next_page_token 

456 

457 res2 = api.ListPublicTrips( 

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

459 ) 

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

461 assert res2.next_page_token 

462 

463 res3 = api.ListPublicTrips( 

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

465 ) 

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

467 assert not res3.next_page_token 

468 

469 

470def test_list_public_trips_by_user_self_sees_all(db): 

471 user, token = generate_user() 

472 other, _ = generate_user() 

473 node_id = _make_node() 

474 

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

476 mine_closed = _create_trip_directly( 

477 user.id, 

478 node_id, 

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

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

481 status=PublicTripStatus.closed, 

482 ) 

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

484 # other user's trip should not be returned 

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

486 

487 with public_trips_session(token) as api: 

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

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

490 

491 

492def test_list_public_trips_by_user_other_filters_inactive_and_past(db): 

493 traveler, _ = generate_user() 

494 _, viewer_token = generate_user() 

495 node_id = _make_node() 

496 

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

498 # closed trip - hidden from others 

499 _create_trip_directly( 

500 traveler.id, 

501 node_id, 

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

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

504 status=PublicTripStatus.closed, 

505 ) 

506 # past trip - hidden from others 

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

508 

509 with public_trips_session(viewer_token) as api: 

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

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

512 

513 

514def test_list_public_trips_by_user_invisible_user(db): 

515 traveler, _ = generate_user() 

516 _, viewer_token = generate_user() 

517 node_id = _make_node() 

518 

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

520 

521 # soft-delete the traveler 

522 with session_scope() as session: 

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

524 t.deleted_at = now() 

525 

526 with public_trips_session(viewer_token) as api: 

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

528 assert len(res.public_trips) == 0 

529 

530 

531def test_update_public_trip_close(db): 

532 user, token = generate_user() 

533 node_id = _make_node() 

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

535 

536 with public_trips_session(token) as api: 

537 res = api.UpdatePublicTrip( 

538 public_trips_pb2.UpdatePublicTripReq( 

539 trip_id=trip_id, 

540 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

541 ) 

542 ) 

543 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

544 

545 with session_scope() as session: 

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

547 assert trip.status == PublicTripStatus.closed 

548 

549 

550def test_update_public_trip_reopen(db): 

551 user, token = generate_user() 

552 node_id = _make_node() 

553 trip_id = _create_trip_directly( 

554 user.id, 

555 node_id, 

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

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

558 status=PublicTripStatus.closed, 

559 ) 

560 

561 with public_trips_session(token) as api: 

562 res = api.UpdatePublicTrip( 

563 public_trips_pb2.UpdatePublicTripReq( 

564 trip_id=trip_id, 

565 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

566 ) 

567 ) 

568 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

569 

570 with session_scope() as session: 

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

572 assert trip.status == PublicTripStatus.searching_for_host 

573 

574 

575def test_update_public_trip_cant_reopen_past_trip(db): 

576 user, token = generate_user() 

577 node_id = _make_node() 

578 trip_id = _create_trip_directly( 

579 user.id, 

580 node_id, 

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

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

583 status=PublicTripStatus.closed, 

584 ) 

585 

586 with public_trips_session(token) as api: 

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

588 api.UpdatePublicTrip( 

589 public_trips_pb2.UpdatePublicTripReq( 

590 trip_id=trip_id, 

591 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

592 ) 

593 ) 

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

595 

596 

597def test_update_public_trip_close_past_trip_allowed(db): 

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

599 user, token = generate_user() 

600 node_id = _make_node() 

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

602 

603 with public_trips_session(token) as api: 

604 res = api.UpdatePublicTrip( 

605 public_trips_pb2.UpdatePublicTripReq( 

606 trip_id=trip_id, 

607 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

608 ) 

609 ) 

610 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

611 

612 

613def test_update_public_trip_description_only(db): 

614 user, token = generate_user() 

615 node_id = _make_node() 

616 trip_id = _create_trip_directly( 

617 user.id, 

618 node_id, 

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

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

621 description="Original description", 

622 ) 

623 

624 updated = VALID_DESCRIPTION + " Updated plans." 

625 

626 with public_trips_session(token) as api: 

627 res = api.UpdatePublicTrip( 

628 public_trips_pb2.UpdatePublicTripReq( 

629 trip_id=trip_id, 

630 description=updated, 

631 ) 

632 ) 

633 assert res.trip_id == trip_id 

634 assert res.description == updated 

635 # dates should be unchanged 

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

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

638 

639 with session_scope() as session: 

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

641 assert trip.description == updated 

642 

643 

644def test_update_public_trip_dates(db): 

645 user, token = generate_user() 

646 node_id = _make_node() 

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

648 

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

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

651 

652 with public_trips_session(token) as api: 

653 res = api.UpdatePublicTrip( 

654 public_trips_pb2.UpdatePublicTripReq( 

655 trip_id=trip_id, 

656 from_date=new_from.isoformat(), 

657 to_date=new_to.isoformat(), 

658 ) 

659 ) 

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

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

662 

663 

664def test_update_public_trip_not_owner(db): 

665 user, _ = generate_user() 

666 _, other_token = generate_user() 

667 node_id = _make_node() 

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

669 

670 with public_trips_session(other_token) as api: 

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

672 api.UpdatePublicTrip( 

673 public_trips_pb2.UpdatePublicTripReq( 

674 trip_id=trip_id, 

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

676 ) 

677 ) 

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

679 

680 

681def test_update_public_trip_in_past(db): 

682 user, token = generate_user() 

683 node_id = _make_node() 

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

685 

686 with public_trips_session(token) as api: 

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

688 api.UpdatePublicTrip( 

689 public_trips_pb2.UpdatePublicTripReq( 

690 trip_id=trip_id, 

691 description="Too late!", 

692 ) 

693 ) 

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

695 

696 

697def test_update_public_trip_date_validation(db): 

698 user, token = generate_user() 

699 node_id = _make_node() 

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

701 

702 with public_trips_session(token) as api: 

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

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

705 api.UpdatePublicTrip( 

706 public_trips_pb2.UpdatePublicTripReq( 

707 trip_id=trip_id, 

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

709 ) 

710 ) 

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

712 

713 # Empty description 

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

715 api.UpdatePublicTrip( 

716 public_trips_pb2.UpdatePublicTripReq( 

717 trip_id=trip_id, 

718 description=" ", 

719 ) 

720 ) 

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

722 

723 

724def test_create_public_trip_description_too_short(db): 

725 _, token = generate_user() 

726 node_id = _make_node() 

727 

728 with public_trips_session(token) as api: 

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

730 api.CreatePublicTrip( 

731 public_trips_pb2.CreatePublicTripReq( 

732 community_id=node_id, 

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

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

735 description="Too short.", 

736 ) 

737 ) 

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

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

740 

741 

742def test_update_public_trip_description_too_short(db): 

743 user, token = generate_user() 

744 node_id = _make_node() 

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

746 

747 with public_trips_session(token) as api: 

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

749 api.UpdatePublicTrip( 

750 public_trips_pb2.UpdatePublicTripReq( 

751 trip_id=trip_id, 

752 description="Too short.", 

753 ) 

754 ) 

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

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

757 

758 

759def test_same_gender_only_create_and_retrieve(db): 

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

761 node_id = _make_node() 

762 

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

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

765 

766 with public_trips_session(token) as api: 

767 res = api.CreatePublicTrip( 

768 public_trips_pb2.CreatePublicTripReq( 

769 community_id=node_id, 

770 from_date=from_date.isoformat(), 

771 to_date=to_date.isoformat(), 

772 description=VALID_DESCRIPTION, 

773 same_gender_only=True, 

774 ) 

775 ) 

776 assert res.same_gender_only is True 

777 

778 with session_scope() as session: 

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

780 assert trip.same_gender_only is True 

781 

782 

783def test_same_gender_only_visibility_list_and_get(db): 

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

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

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

787 node_id = _make_node() 

788 

789 filtered_trip_id = _create_trip_directly( 

790 traveler.id, 

791 node_id, 

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

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

794 same_gender_only=True, 

795 ) 

796 open_trip_id = _create_trip_directly( 

797 traveler.id, 

798 node_id, 

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

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

801 ) 

802 

803 with public_trips_session(same_gender_token) as api: 

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

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

806 

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

808 assert get_res.trip_id == filtered_trip_id 

809 assert get_res.same_gender_only is True 

810 

811 with public_trips_session(diff_gender_token) as api: 

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

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

814 

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

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

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

818 

819 

820def test_same_gender_only_moderator_bypass(db): 

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

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

823 node_id = _make_node() 

824 _make_node_admin(mod.id, node_id) 

825 

826 trip_id = _create_trip_directly( 

827 traveler.id, 

828 node_id, 

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

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

831 same_gender_only=True, 

832 ) 

833 

834 with public_trips_session(mod_token) as api: 

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

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

837 

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

839 assert get_res.trip_id == trip_id 

840 

841 

842def test_same_gender_only_owner_always_sees_own_trips(db): 

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

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

845 node_id = _make_node() 

846 

847 trip_id = _create_trip_directly( 

848 traveler.id, 

849 node_id, 

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

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

852 same_gender_only=True, 

853 ) 

854 

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

856 with public_trips_session(traveler_token) as api: 

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

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

859 

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

861 with public_trips_session(diff_gender_token) as api: 

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

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

864 

865 

866def test_same_gender_only_update(db): 

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

868 node_id = _make_node() 

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

870 

871 with public_trips_session(token) as api: 

872 res = api.UpdatePublicTrip( 

873 public_trips_pb2.UpdatePublicTripReq( 

874 trip_id=trip_id, 

875 same_gender_only=True, 

876 ) 

877 ) 

878 assert res.same_gender_only is True 

879 

880 res = api.UpdatePublicTrip( 

881 public_trips_pb2.UpdatePublicTripReq( 

882 trip_id=trip_id, 

883 same_gender_only=False, 

884 ) 

885 ) 

886 assert res.same_gender_only is False 

887 

888 with session_scope() as session: 

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

890 assert trip.same_gender_only is False 

891 

892 

893def test_list_public_trips_by_user_ascending_order(db): 

894 user, token = generate_user() 

895 node_id = _make_node() 

896 

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

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

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

900 

901 with public_trips_session(token) as api: 

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

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

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

905 

906 res_desc = api.ListPublicTripsByUser( 

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

908 ) 

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

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

911 

912 

913def test_list_public_trips_by_user_status_filter(db): 

914 user, token = generate_user() 

915 node_id = _make_node() 

916 

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

918 closed = _create_trip_directly( 

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

920 ) 

921 

922 with public_trips_session(token) as api: 

923 # Filter to active only 

924 res = api.ListPublicTripsByUser( 

925 public_trips_pb2.ListPublicTripsByUserReq( 

926 user_id=user.id, 

927 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST], 

928 ) 

929 ) 

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

931 

932 # Filter to closed only 

933 res = api.ListPublicTripsByUser( 

934 public_trips_pb2.ListPublicTripsByUserReq( 

935 user_id=user.id, 

936 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

937 ) 

938 ) 

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

940 

941 # No filter returns all 

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

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

944 

945 

946def test_list_public_trips_by_user_status_filter_ignored_for_others(db): 

947 traveler, _ = generate_user() 

948 _, viewer_token = generate_user() 

949 node_id = _make_node() 

950 

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

952 _create_trip_directly( 

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

954 ) 

955 

956 with public_trips_session(viewer_token) as api: 

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

958 res = api.ListPublicTripsByUser( 

959 public_trips_pb2.ListPublicTripsByUserReq( 

960 user_id=traveler.id, 

961 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

962 ) 

963 ) 

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

965 

966 

967def test_list_public_trips_by_user_offers_count_owner(db, moderator: Moderator): 

968 traveler, traveler_token = generate_user() 

969 host, host_token = generate_user() 

970 node_id = _make_node() 

971 

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

973 

974 with public_trips_session(traveler_token) as api: 

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

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

977 assert trip.HasField("offers_count") 

978 assert trip.offers_count == 0 

979 

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

981 with requests_session(host_token) as api: 

982 host_request_id = api.CreateHostRequest( 

983 requests_pb2.CreateHostRequestReq( 

984 host_user_id=traveler.id, 

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

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

987 text=_valid_request_text(), 

988 public_trip_id=trip_id, 

989 ) 

990 ).host_request_id 

991 

992 with public_trips_session(traveler_token) as api: 

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

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

995 assert trip.HasField("offers_count") 

996 assert trip.offers_count == 0 

997 

998 moderator.approve_host_request(host_request_id) 

999 

1000 with public_trips_session(traveler_token) as api: 

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

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

1003 assert trip.HasField("offers_count") 

1004 assert trip.offers_count == 1 

1005 

1006 

1007def test_list_public_trips_by_user_offers_count_excludes_invisible_offers(db, moderator: Moderator): 

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

1009 traveler, traveler_token = generate_user() 

1010 _hidden_host, hidden_host_token = generate_user() 

1011 banned_host, banned_host_token = generate_user() 

1012 node_id = _make_node() 

1013 

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

1015 

1016 offer_ids = [] 

1017 for token in (hidden_host_token, banned_host_token): 

1018 with requests_session(token) as api: 

1019 offer_ids.append( 

1020 api.CreateHostRequest( 

1021 requests_pb2.CreateHostRequestReq( 

1022 host_user_id=traveler.id, 

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

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

1025 text=_valid_request_text(), 

1026 public_trip_id=trip_id, 

1027 ) 

1028 ).host_request_id 

1029 ) 

1030 hidden_offer_id = offer_ids[0] 

1031 

1032 for offer_id in offer_ids: 

1033 moderator.approve_host_request(offer_id) 

1034 

1035 with public_trips_session(traveler_token) as api: 

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

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

1038 

1039 moderator.hide_host_request(hidden_offer_id) 

1040 with session_scope() as session: 

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

1042 

1043 with public_trips_session(traveler_token) as api: 

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

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

1046 

1047 

1048def test_list_public_trips_by_user_offers_count_not_set_for_others(db): 

1049 traveler, _ = generate_user() 

1050 _, viewer_token = generate_user() 

1051 node_id = _make_node() 

1052 

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

1054 

1055 with public_trips_session(viewer_token) as api: 

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

1057 assert len(res.public_trips) == 1 

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

1059 

1060 

1061def test_list_public_trips_by_user_offers_count_excludes_cancelled(db, moderator: Moderator): 

1062 traveler, traveler_token = generate_user() 

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

1064 node_id = _make_node() 

1065 

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

1067 

1068 for _host, host_token in hosts: 

1069 with requests_session(host_token) as api: 

1070 offer_id = api.CreateHostRequest( 

1071 requests_pb2.CreateHostRequestReq( 

1072 host_user_id=traveler.id, 

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

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

1075 text=_valid_request_text(), 

1076 public_trip_id=trip_id, 

1077 ) 

1078 ).host_request_id 

1079 moderator.approve_host_request(offer_id) 

1080 

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

1082 with session_scope() as session: 

1083 offers = ( 

1084 session.execute( 

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

1086 ) 

1087 .scalars() 

1088 .all() 

1089 ) 

1090 offers[0].status = HostRequestStatus.pending 

1091 offers[1].status = HostRequestStatus.accepted 

1092 offers[2].status = HostRequestStatus.confirmed 

1093 offers[3].status = HostRequestStatus.rejected 

1094 offers[4].status = HostRequestStatus.cancelled 

1095 

1096 with public_trips_session(traveler_token) as api: 

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

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

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

1100 assert trip.offers_count == 4 

1101 

1102 

1103def test_viewer_host_request_id_reflects_viewers_own_offer(db): 

1104 traveler, _ = generate_user() 

1105 host, host_token = generate_user() 

1106 _, other_token = generate_user() 

1107 node_id = _make_node() 

1108 

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

1110 

1111 # Before offering, the host sees 0. 

1112 with public_trips_session(host_token) as api: 

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

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

1115 assert trip.viewer_host_request_id == 0 

1116 

1117 # The host makes an offer on the trip. 

1118 with requests_session(host_token) as api: 

1119 create_res = api.CreateHostRequest( 

1120 requests_pb2.CreateHostRequestReq( 

1121 host_user_id=traveler.id, 

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

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

1124 text=_valid_request_text(), 

1125 public_trip_id=trip_id, 

1126 ) 

1127 ) 

1128 host_request_id = create_res.host_request_id 

1129 

1130 with public_trips_session(host_token) as api: 

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

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

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

1134 assert trip.viewer_host_request_id == host_request_id 

1135 

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

1137 assert get_res.viewer_host_request_id == host_request_id 

1138 

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

1140 with public_trips_session(other_token) as api: 

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

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

1143 assert trip.viewer_host_request_id == 0 

1144 

1145 

1146def test_create_public_trip_creates_shadowed_moderation_state(db): 

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

1148 user, token = generate_user() 

1149 node_id = _make_node() 

1150 

1151 with public_trips_session(token) as api: 

1152 res = api.CreatePublicTrip( 

1153 public_trips_pb2.CreatePublicTripReq( 

1154 community_id=node_id, 

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

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

1157 description=VALID_DESCRIPTION, 

1158 ) 

1159 ) 

1160 

1161 with session_scope() as session: 

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

1163 state = session.execute( 

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

1165 ).scalar_one() 

1166 assert state.object_type == ModerationObjectType.public_trip 

1167 assert state.object_id == trip.id 

1168 assert state.visibility == ModerationVisibility.shadowed 

1169 

1170 queue_item = session.execute( 

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

1172 ).scalar_one() 

1173 assert queue_item.trigger == ModerationTrigger.initial_review 

1174 assert queue_item.resolved_by_log_id is None 

1175 

1176 

1177def test_shadowed_public_trip_visible_to_author_hidden_from_others(db): 

1178 traveler, traveler_token = generate_user() 

1179 _, viewer_token = generate_user() 

1180 node_id = _make_node() 

1181 

1182 trip_id = _create_trip_directly( 

1183 traveler.id, 

1184 node_id, 

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

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

1187 visibility=ModerationVisibility.shadowed, 

1188 ) 

1189 

1190 # Author can fetch their own shadowed trip 

1191 with public_trips_session(traveler_token) as api: 

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

1193 assert res.trip_id == trip_id 

1194 

1195 # Author sees their shadowed trip in their own listing 

1196 with public_trips_session(traveler_token) as api: 

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

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

1199 

1200 # Other viewers can't see it 

1201 with public_trips_session(viewer_token) as api: 

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

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

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

1205 

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

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

1208 

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

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

1211 

1212 

1213def test_hidden_public_trip_invisible_to_everyone(db): 

1214 traveler, traveler_token = generate_user() 

1215 _, viewer_token = generate_user() 

1216 node_id = _make_node() 

1217 

1218 trip_id = _create_trip_directly( 

1219 traveler.id, 

1220 node_id, 

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

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

1223 visibility=ModerationVisibility.hidden, 

1224 ) 

1225 

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

1227 with public_trips_session(traveler_token) as api: 

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

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

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

1231 

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

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

1234 

1235 with public_trips_session(viewer_token) as api: 

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

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

1238 

1239 

1240def test_moderator_approval_makes_public_trip_visible(db, moderator): 

1241 user, token = generate_user() 

1242 _, viewer_token = generate_user() 

1243 node_id = _make_node() 

1244 

1245 with public_trips_session(token) as api: 

1246 res = api.CreatePublicTrip( 

1247 public_trips_pb2.CreatePublicTripReq( 

1248 community_id=node_id, 

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

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

1251 description=VALID_DESCRIPTION, 

1252 ) 

1253 ) 

1254 

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

1256 with public_trips_session(viewer_token) as api: 

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

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

1259 

1260 moderator.approve_public_trip(res.trip_id) 

1261 

1262 # After approval: visible to everyone 

1263 with public_trips_session(viewer_token) as api: 

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

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

1266 

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

1268 assert get_res.trip_id == res.trip_id 

1269 

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

1271 with session_scope() as session: 

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

1273 queue_item = session.execute( 

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

1275 ).scalar_one() 

1276 assert queue_item.resolved_by_log_id is not None