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

507 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-22 16:01 +0000

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 Cluster, ClusterRole, ClusterSubscription, Node, NodeType, User 

11from couchers.models.host_requests import HostRequest, HostRequestStatus 

12from couchers.models.public_trips import PublicTrip, PublicTripStatus 

13from couchers.proto import public_trips_pb2, requests_pb2 

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

15from tests.fixtures.db import generate_user 

16from tests.fixtures.sessions import public_trips_session, requests_session 

17 

18 

19def _valid_request_text(text: str = "Offer to host") -> str: 

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

21 if utf16_length >= HOST_REQUEST_MIN_LENGTH_UTF16: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true

22 return text 

23 return text + "_" * (HOST_REQUEST_MIN_LENGTH_UTF16 - utf16_length) 

24 

25 

26@pytest.fixture(autouse=True) 

27def _(testconfig): 

28 pass 

29 

30 

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

32VALID_DESCRIPTION = ( 

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

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

35) 

36 

37 

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

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

40 with session_scope() as session: 

41 node = Node( 

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

43 node_type=node_type, 

44 ) 

45 session.add(node) 

46 session.flush() 

47 cluster = Cluster( 

48 name="Test community", 

49 description="Test", 

50 parent_node_id=node.id, 

51 is_official_cluster=True, 

52 small_community_features_enabled=small_community_features_enabled, 

53 ) 

54 session.add(cluster) 

55 session.flush() 

56 return node.id 

57 

58 

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

60 with session_scope() as session: 

61 cluster_id = session.execute( 

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

63 ).scalar_one() 

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

65 

66 

67def _create_trip_directly( 

68 user_id: int, 

69 node_id: int, 

70 from_date, 

71 to_date, 

72 *, 

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

74 status=None, 

75 same_gender_only: bool = False, 

76) -> int: 

77 with session_scope() as session: 

78 trip = PublicTrip( 

79 user_id=user_id, 

80 node_id=node_id, 

81 from_date=from_date, 

82 to_date=to_date, 

83 description=description, 

84 status=status or PublicTripStatus.searching_for_host, 

85 same_gender_only=same_gender_only, 

86 ) 

87 session.add(trip) 

88 session.flush() 

89 return trip.id 

90 

91 

92def test_create_public_trip(db): 

93 user, token = generate_user() 

94 node_id = _make_node() 

95 

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

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

98 

99 with public_trips_session(token) as api: 

100 res = api.CreatePublicTrip( 

101 public_trips_pb2.CreatePublicTripReq( 

102 community_id=node_id, 

103 from_date=from_date.isoformat(), 

104 to_date=to_date.isoformat(), 

105 description=VALID_DESCRIPTION, 

106 ) 

107 ) 

108 

109 assert res.trip_id > 0 

110 assert res.user.user_id == user.id 

111 assert res.community_id == node_id 

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

113 assert res.community_name == "Test community" 

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

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

116 assert res.description == VALID_DESCRIPTION 

117 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

118 

119 with session_scope() as session: 

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

121 assert trip.user_id == user.id 

122 assert trip.node_id == node_id 

123 assert trip.status == PublicTripStatus.searching_for_host 

124 

125 

126def test_create_public_trip_incomplete_profile(db): 

127 _, token = generate_user(complete_profile=False) 

128 node_id = _make_node() 

129 

130 with public_trips_session(token) as api: 

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

132 api.CreatePublicTrip( 

133 public_trips_pb2.CreatePublicTripReq( 

134 community_id=node_id, 

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

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

137 description="Visiting town!", 

138 ) 

139 ) 

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

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

142 

143 

144def test_create_public_trip_community_not_found(db): 

145 _, token = generate_user() 

146 

147 with public_trips_session(token) as api: 

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

149 api.CreatePublicTrip( 

150 public_trips_pb2.CreatePublicTripReq( 

151 community_id=999999, 

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

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

154 description="Visiting town!", 

155 ) 

156 ) 

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

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

159 

160 

161def test_create_public_trip_not_enabled(db): 

162 _, token = generate_user() 

163 node_id = _make_node(small_community_features_enabled=False) 

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

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

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

172 description="Visiting town!", 

173 ) 

174 ) 

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

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

177 

178 

179@pytest.mark.parametrize( 

180 "node_type", 

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

182) 

183def test_create_public_trip_allows_region_and_narrower(db, node_type): 

184 _, token = generate_user() 

185 node_id = _make_node(node_type=node_type) 

186 

187 with public_trips_session(token) as api: 

188 res = api.CreatePublicTrip( 

189 public_trips_pb2.CreatePublicTripReq( 

190 community_id=node_id, 

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

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

193 description=VALID_DESCRIPTION, 

194 ) 

195 ) 

196 assert res.trip_id > 0 

197 

198 

199def test_create_public_trip_in_past_uses_node_timezone(db): 

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

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

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

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

204 _, token = generate_user() 

205 node_id = _make_node() 

206 

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

208 

209 with patch( 

210 "couchers.servicers.public_trips.today_in_timezone", 

211 side_effect=lambda tz: fake_today_by_tz[tz], 

212 ): 

213 with public_trips_session(token) as api: 

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

215 api.CreatePublicTrip( 

216 public_trips_pb2.CreatePublicTripReq( 

217 community_id=node_id, 

218 from_date="2026-01-15", 

219 to_date="2026-01-20", 

220 description=VALID_DESCRIPTION, 

221 ) 

222 ) 

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

224 

225 

226def test_create_public_trip_date_errors(db): 

227 _, token = generate_user() 

228 node_id = _make_node() 

229 

230 with public_trips_session(token) as api: 

231 # from_date in the past 

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

233 api.CreatePublicTrip( 

234 public_trips_pb2.CreatePublicTripReq( 

235 community_id=node_id, 

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

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

238 description="Visiting town!", 

239 ) 

240 ) 

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

242 

243 # from_date after to_date 

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

245 api.CreatePublicTrip( 

246 public_trips_pb2.CreatePublicTripReq( 

247 community_id=node_id, 

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

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

250 description="Visiting town!", 

251 ) 

252 ) 

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

254 

255 # empty description 

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

257 api.CreatePublicTrip( 

258 public_trips_pb2.CreatePublicTripReq( 

259 community_id=node_id, 

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

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

262 description=" ", 

263 ) 

264 ) 

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

266 

267 # invalid date format 

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

269 api.CreatePublicTrip( 

270 public_trips_pb2.CreatePublicTripReq( 

271 community_id=node_id, 

272 from_date="not-a-date", 

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

274 description="Visiting town!", 

275 ) 

276 ) 

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

278 

279 

280def test_create_public_trip_overlap(db): 

281 user, token = generate_user() 

282 node_id = _make_node() 

283 

284 _create_trip_directly( 

285 user.id, 

286 node_id, 

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

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

289 ) 

290 

291 with public_trips_session(token) as api: 

292 # overlapping dates should fail 

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

294 api.CreatePublicTrip( 

295 public_trips_pb2.CreatePublicTripReq( 

296 community_id=node_id, 

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

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

299 description=VALID_DESCRIPTION, 

300 ) 

301 ) 

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

303 

304 # non-overlapping dates should succeed 

305 res = api.CreatePublicTrip( 

306 public_trips_pb2.CreatePublicTripReq( 

307 community_id=node_id, 

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

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

310 description=VALID_DESCRIPTION, 

311 ) 

312 ) 

313 assert res.trip_id > 0 

314 

315 

316def test_create_public_trip_closed_trip_allows_new_overlap(db): 

317 user, token = generate_user() 

318 node_id = _make_node() 

319 

320 _create_trip_directly( 

321 user.id, 

322 node_id, 

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

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

325 status=PublicTripStatus.closed, 

326 ) 

327 

328 with public_trips_session(token) as api: 

329 # closed trips shouldn't block new overlapping ones 

330 res = api.CreatePublicTrip( 

331 public_trips_pb2.CreatePublicTripReq( 

332 community_id=node_id, 

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

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

335 description=VALID_DESCRIPTION, 

336 ) 

337 ) 

338 assert res.trip_id > 0 

339 

340 

341def test_get_public_trip(db): 

342 user, token = generate_user() 

343 node_id = _make_node() 

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

345 

346 with public_trips_session(token) as api: 

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

348 assert res.trip_id == trip_id 

349 assert res.user.user_id == user.id 

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

351 assert res.community_name == "Test community" 

352 

353 

354def test_get_public_trip_not_found(db): 

355 _, token = generate_user() 

356 with public_trips_session(token) as api: 

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

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

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

360 

361 

362def test_list_public_trips(db): 

363 traveler, _ = generate_user() 

364 _, host_token = generate_user() 

365 node_id = _make_node() 

366 

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

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

369 

370 with public_trips_session(host_token) as api: 

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

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

373 assert returned_ids == {trip1, trip2} 

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

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

376 

377 

378def test_list_public_trips_filters_closed_and_past(db): 

379 traveler, _ = generate_user() 

380 _, host_token = generate_user() 

381 node_id = _make_node() 

382 

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

384 # closed trip - should be hidden 

385 _create_trip_directly( 

386 traveler.id, 

387 node_id, 

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

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

390 status=PublicTripStatus.closed, 

391 ) 

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

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

394 

395 with public_trips_session(host_token) as api: 

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

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

398 

399 

400def test_list_public_trips_hides_invisible_user(db): 

401 traveler, _ = generate_user() 

402 _, host_token = generate_user() 

403 node_id = _make_node() 

404 

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

406 

407 # soft-delete the traveler 

408 with session_scope() as session: 

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

410 t.deleted_at = now() 

411 

412 with public_trips_session(host_token) as api: 

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

414 assert len(res.public_trips) == 0 

415 

416 

417def test_list_public_trips_pagination(db): 

418 traveler, _ = generate_user() 

419 _, host_token = generate_user() 

420 node_id = _make_node() 

421 

422 trip_ids = [ 

423 _create_trip_directly( 

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

425 ) 

426 for i in range(5) 

427 ] 

428 

429 with public_trips_session(host_token) as api: 

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

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

432 assert res.next_page_token 

433 

434 res2 = api.ListPublicTrips( 

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

436 ) 

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

438 assert res2.next_page_token 

439 

440 res3 = api.ListPublicTrips( 

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

442 ) 

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

444 assert not res3.next_page_token 

445 

446 

447def test_list_public_trips_by_user_self_sees_all(db): 

448 user, token = generate_user() 

449 other, _ = generate_user() 

450 node_id = _make_node() 

451 

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

453 mine_closed = _create_trip_directly( 

454 user.id, 

455 node_id, 

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

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

458 status=PublicTripStatus.closed, 

459 ) 

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

461 # other user's trip should not be returned 

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

463 

464 with public_trips_session(token) as api: 

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

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

467 

468 

469def test_list_public_trips_by_user_other_filters_inactive_and_past(db): 

470 traveler, _ = generate_user() 

471 _, viewer_token = generate_user() 

472 node_id = _make_node() 

473 

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

475 # closed trip - hidden from others 

476 _create_trip_directly( 

477 traveler.id, 

478 node_id, 

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

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

481 status=PublicTripStatus.closed, 

482 ) 

483 # past trip - hidden from others 

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

485 

486 with public_trips_session(viewer_token) as api: 

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

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

489 

490 

491def test_list_public_trips_by_user_invisible_user(db): 

492 traveler, _ = generate_user() 

493 _, viewer_token = generate_user() 

494 node_id = _make_node() 

495 

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

497 

498 # soft-delete the traveler 

499 with session_scope() as session: 

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

501 t.deleted_at = now() 

502 

503 with public_trips_session(viewer_token) as api: 

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

505 assert len(res.public_trips) == 0 

506 

507 

508def test_update_public_trip_close(db): 

509 user, token = generate_user() 

510 node_id = _make_node() 

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

512 

513 with public_trips_session(token) as api: 

514 res = api.UpdatePublicTrip( 

515 public_trips_pb2.UpdatePublicTripReq( 

516 trip_id=trip_id, 

517 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

518 ) 

519 ) 

520 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

521 

522 with session_scope() as session: 

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

524 assert trip.status == PublicTripStatus.closed 

525 

526 

527def test_update_public_trip_reopen(db): 

528 user, token = generate_user() 

529 node_id = _make_node() 

530 trip_id = _create_trip_directly( 

531 user.id, 

532 node_id, 

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

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

535 status=PublicTripStatus.closed, 

536 ) 

537 

538 with public_trips_session(token) as api: 

539 res = api.UpdatePublicTrip( 

540 public_trips_pb2.UpdatePublicTripReq( 

541 trip_id=trip_id, 

542 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

543 ) 

544 ) 

545 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

546 

547 with session_scope() as session: 

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

549 assert trip.status == PublicTripStatus.searching_for_host 

550 

551 

552def test_update_public_trip_cant_reopen_past_trip(db): 

553 user, token = generate_user() 

554 node_id = _make_node() 

555 trip_id = _create_trip_directly( 

556 user.id, 

557 node_id, 

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

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

560 status=PublicTripStatus.closed, 

561 ) 

562 

563 with public_trips_session(token) as api: 

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

565 api.UpdatePublicTrip( 

566 public_trips_pb2.UpdatePublicTripReq( 

567 trip_id=trip_id, 

568 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

569 ) 

570 ) 

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

572 

573 

574def test_update_public_trip_close_past_trip_allowed(db): 

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

576 user, token = generate_user() 

577 node_id = _make_node() 

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

579 

580 with public_trips_session(token) as api: 

581 res = api.UpdatePublicTrip( 

582 public_trips_pb2.UpdatePublicTripReq( 

583 trip_id=trip_id, 

584 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

585 ) 

586 ) 

587 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

588 

589 

590def test_update_public_trip_description_only(db): 

591 user, token = generate_user() 

592 node_id = _make_node() 

593 trip_id = _create_trip_directly( 

594 user.id, 

595 node_id, 

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

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

598 description="Original description", 

599 ) 

600 

601 updated = VALID_DESCRIPTION + " Updated plans." 

602 

603 with public_trips_session(token) as api: 

604 res = api.UpdatePublicTrip( 

605 public_trips_pb2.UpdatePublicTripReq( 

606 trip_id=trip_id, 

607 description=updated, 

608 ) 

609 ) 

610 assert res.trip_id == trip_id 

611 assert res.description == updated 

612 # dates should be unchanged 

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

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

615 

616 with session_scope() as session: 

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

618 assert trip.description == updated 

619 

620 

621def test_update_public_trip_dates(db): 

622 user, token = generate_user() 

623 node_id = _make_node() 

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

625 

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

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

628 

629 with public_trips_session(token) as api: 

630 res = api.UpdatePublicTrip( 

631 public_trips_pb2.UpdatePublicTripReq( 

632 trip_id=trip_id, 

633 from_date=new_from.isoformat(), 

634 to_date=new_to.isoformat(), 

635 ) 

636 ) 

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

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

639 

640 

641def test_update_public_trip_not_owner(db): 

642 user, _ = generate_user() 

643 _, other_token = generate_user() 

644 node_id = _make_node() 

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

646 

647 with public_trips_session(other_token) as api: 

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

649 api.UpdatePublicTrip( 

650 public_trips_pb2.UpdatePublicTripReq( 

651 trip_id=trip_id, 

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

653 ) 

654 ) 

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

656 

657 

658def test_update_public_trip_in_past(db): 

659 user, token = generate_user() 

660 node_id = _make_node() 

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

662 

663 with public_trips_session(token) as api: 

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

665 api.UpdatePublicTrip( 

666 public_trips_pb2.UpdatePublicTripReq( 

667 trip_id=trip_id, 

668 description="Too late!", 

669 ) 

670 ) 

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

672 

673 

674def test_update_public_trip_date_validation(db): 

675 user, token = generate_user() 

676 node_id = _make_node() 

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

678 

679 with public_trips_session(token) as api: 

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

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

682 api.UpdatePublicTrip( 

683 public_trips_pb2.UpdatePublicTripReq( 

684 trip_id=trip_id, 

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

686 ) 

687 ) 

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

689 

690 # Empty description 

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

692 api.UpdatePublicTrip( 

693 public_trips_pb2.UpdatePublicTripReq( 

694 trip_id=trip_id, 

695 description=" ", 

696 ) 

697 ) 

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

699 

700 

701def test_create_public_trip_description_too_short(db): 

702 _, token = generate_user() 

703 node_id = _make_node() 

704 

705 with public_trips_session(token) as api: 

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

707 api.CreatePublicTrip( 

708 public_trips_pb2.CreatePublicTripReq( 

709 community_id=node_id, 

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

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

712 description="Too short.", 

713 ) 

714 ) 

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

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

717 

718 

719def test_update_public_trip_description_too_short(db): 

720 user, token = generate_user() 

721 node_id = _make_node() 

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

723 

724 with public_trips_session(token) as api: 

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

726 api.UpdatePublicTrip( 

727 public_trips_pb2.UpdatePublicTripReq( 

728 trip_id=trip_id, 

729 description="Too short.", 

730 ) 

731 ) 

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

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

734 

735 

736def test_same_gender_only_create_and_retrieve(db): 

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

738 node_id = _make_node() 

739 

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

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

742 

743 with public_trips_session(token) as api: 

744 res = api.CreatePublicTrip( 

745 public_trips_pb2.CreatePublicTripReq( 

746 community_id=node_id, 

747 from_date=from_date.isoformat(), 

748 to_date=to_date.isoformat(), 

749 description=VALID_DESCRIPTION, 

750 same_gender_only=True, 

751 ) 

752 ) 

753 assert res.same_gender_only is True 

754 

755 with session_scope() as session: 

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

757 assert trip.same_gender_only is True 

758 

759 

760def test_same_gender_only_visibility_list_and_get(db): 

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

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

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

764 node_id = _make_node() 

765 

766 filtered_trip_id = _create_trip_directly( 

767 traveler.id, 

768 node_id, 

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

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

771 same_gender_only=True, 

772 ) 

773 open_trip_id = _create_trip_directly( 

774 traveler.id, 

775 node_id, 

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

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

778 ) 

779 

780 with public_trips_session(same_gender_token) as api: 

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

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

783 

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

785 assert get_res.trip_id == filtered_trip_id 

786 assert get_res.same_gender_only is True 

787 

788 with public_trips_session(diff_gender_token) as api: 

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

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

791 

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

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

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

795 

796 

797def test_same_gender_only_moderator_bypass(db): 

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

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

800 node_id = _make_node() 

801 _make_node_admin(mod.id, node_id) 

802 

803 trip_id = _create_trip_directly( 

804 traveler.id, 

805 node_id, 

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

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

808 same_gender_only=True, 

809 ) 

810 

811 with public_trips_session(mod_token) as api: 

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

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

814 

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

816 assert get_res.trip_id == trip_id 

817 

818 

819def test_same_gender_only_owner_always_sees_own_trips(db): 

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

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

822 node_id = _make_node() 

823 

824 trip_id = _create_trip_directly( 

825 traveler.id, 

826 node_id, 

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

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

829 same_gender_only=True, 

830 ) 

831 

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

833 with public_trips_session(traveler_token) as api: 

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

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

836 

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

838 with public_trips_session(diff_gender_token) as api: 

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

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

841 

842 

843def test_same_gender_only_update(db): 

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

845 node_id = _make_node() 

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

847 

848 with public_trips_session(token) as api: 

849 res = api.UpdatePublicTrip( 

850 public_trips_pb2.UpdatePublicTripReq( 

851 trip_id=trip_id, 

852 same_gender_only=True, 

853 ) 

854 ) 

855 assert res.same_gender_only is True 

856 

857 res = api.UpdatePublicTrip( 

858 public_trips_pb2.UpdatePublicTripReq( 

859 trip_id=trip_id, 

860 same_gender_only=False, 

861 ) 

862 ) 

863 assert res.same_gender_only is False 

864 

865 with session_scope() as session: 

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

867 assert trip.same_gender_only is False 

868 

869 

870def test_list_public_trips_by_user_ascending_order(db): 

871 user, token = generate_user() 

872 node_id = _make_node() 

873 

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

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

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

877 

878 with public_trips_session(token) as api: 

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

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

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

882 

883 res_desc = api.ListPublicTripsByUser( 

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

885 ) 

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

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

888 

889 

890def test_list_public_trips_by_user_status_filter(db): 

891 user, token = generate_user() 

892 node_id = _make_node() 

893 

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

895 closed = _create_trip_directly( 

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

897 ) 

898 

899 with public_trips_session(token) as api: 

900 # Filter to active only 

901 res = api.ListPublicTripsByUser( 

902 public_trips_pb2.ListPublicTripsByUserReq( 

903 user_id=user.id, 

904 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST], 

905 ) 

906 ) 

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

908 

909 # Filter to closed only 

910 res = api.ListPublicTripsByUser( 

911 public_trips_pb2.ListPublicTripsByUserReq( 

912 user_id=user.id, 

913 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

914 ) 

915 ) 

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

917 

918 # No filter returns all 

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

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

921 

922 

923def test_list_public_trips_by_user_status_filter_ignored_for_others(db): 

924 traveler, _ = generate_user() 

925 _, viewer_token = generate_user() 

926 node_id = _make_node() 

927 

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

929 _create_trip_directly( 

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

931 ) 

932 

933 with public_trips_session(viewer_token) as api: 

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

935 res = api.ListPublicTripsByUser( 

936 public_trips_pb2.ListPublicTripsByUserReq( 

937 user_id=traveler.id, 

938 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

939 ) 

940 ) 

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

942 

943 

944def test_list_public_trips_by_user_offers_count_owner(db): 

945 traveler, traveler_token = generate_user() 

946 host, host_token = generate_user() 

947 node_id = _make_node() 

948 

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

950 

951 with public_trips_session(traveler_token) as api: 

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

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

954 assert trip.HasField("offers_count") 

955 assert trip.offers_count == 0 

956 

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

958 with requests_session(host_token) as api: 

959 api.CreateHostRequest( 

960 requests_pb2.CreateHostRequestReq( 

961 host_user_id=traveler.id, 

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

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

964 text=_valid_request_text(), 

965 public_trip_id=trip_id, 

966 ) 

967 ) 

968 

969 with public_trips_session(traveler_token) as api: 

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

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

972 assert trip.HasField("offers_count") 

973 assert trip.offers_count == 1 

974 

975 

976def test_list_public_trips_by_user_offers_count_not_set_for_others(db): 

977 traveler, _ = generate_user() 

978 _, viewer_token = generate_user() 

979 node_id = _make_node() 

980 

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

982 

983 with public_trips_session(viewer_token) as api: 

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

985 assert len(res.public_trips) == 1 

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

987 assert not res.public_trips[0].HasField("offer_tally") 

988 

989 

990def test_list_public_trips_by_user_offer_tally_owner(db): 

991 traveler, traveler_token = generate_user() 

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

993 node_id = _make_node() 

994 

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

996 

997 for _host, host_token in hosts: 

998 with requests_session(host_token) as api: 

999 api.CreateHostRequest( 

1000 requests_pb2.CreateHostRequestReq( 

1001 host_user_id=traveler.id, 

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

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

1004 text=_valid_request_text(), 

1005 public_trip_id=trip_id, 

1006 ) 

1007 ) 

1008 

1009 # Set one offer per status: pending, accepted, confirmed, rejected, cancelled. 

1010 with session_scope() as session: 

1011 offers = ( 

1012 session.execute( 

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

1014 ) 

1015 .scalars() 

1016 .all() 

1017 ) 

1018 offers[0].status = HostRequestStatus.pending 

1019 offers[1].status = HostRequestStatus.accepted 

1020 offers[2].status = HostRequestStatus.confirmed 

1021 offers[3].status = HostRequestStatus.rejected 

1022 offers[4].status = HostRequestStatus.cancelled 

1023 

1024 with public_trips_session(traveler_token) as api: 

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

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

1027 assert trip.HasField("offer_tally") 

1028 assert trip.offer_tally.pending == 1 

1029 assert trip.offer_tally.accepted == 1 

1030 assert trip.offer_tally.confirmed == 1 

1031 assert trip.offer_tally.declined == 1 

1032 # cancelled is excluded from offers_count and the tally 

1033 assert trip.offers_count == 4 

1034 

1035 

1036def test_viewer_host_request_id_reflects_viewers_own_offer(db): 

1037 traveler, _ = generate_user() 

1038 host, host_token = generate_user() 

1039 _, other_token = generate_user() 

1040 node_id = _make_node() 

1041 

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

1043 

1044 # Before offering, the host sees 0. 

1045 with public_trips_session(host_token) as api: 

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

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

1048 assert trip.viewer_host_request_id == 0 

1049 

1050 # The host makes an offer on the trip. 

1051 with requests_session(host_token) as api: 

1052 create_res = api.CreateHostRequest( 

1053 requests_pb2.CreateHostRequestReq( 

1054 host_user_id=traveler.id, 

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

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

1057 text=_valid_request_text(), 

1058 public_trip_id=trip_id, 

1059 ) 

1060 ) 

1061 host_request_id = create_res.host_request_id 

1062 

1063 with public_trips_session(host_token) as api: 

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

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

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

1067 assert trip.viewer_host_request_id == host_request_id 

1068 

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

1070 assert get_res.viewer_host_request_id == host_request_id 

1071 

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

1073 with public_trips_session(other_token) as api: 

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

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

1076 assert trip.viewer_host_request_id == 0