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

481 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-20 15:12 +0000

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.public_trips import PublicTrip, PublicTripStatus 

12from couchers.proto import public_trips_pb2, requests_pb2 

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

14from tests.fixtures.db import generate_user 

15from tests.fixtures.sessions import public_trips_session, requests_session 

16 

17 

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

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

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

21 return text 

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

23 

24 

25@pytest.fixture(autouse=True) 

26def _(testconfig): 

27 pass 

28 

29 

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

31VALID_DESCRIPTION = ( 

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

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

34) 

35 

36 

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

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

39 with session_scope() as session: 

40 node = Node( 

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

42 node_type=node_type, 

43 ) 

44 session.add(node) 

45 session.flush() 

46 cluster = Cluster( 

47 name="Test community", 

48 description="Test", 

49 parent_node_id=node.id, 

50 is_official_cluster=True, 

51 small_community_features_enabled=small_community_features_enabled, 

52 ) 

53 session.add(cluster) 

54 session.flush() 

55 return node.id 

56 

57 

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

59 with session_scope() as session: 

60 cluster_id = session.execute( 

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

62 ).scalar_one() 

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

64 

65 

66def _create_trip_directly( 

67 user_id: int, 

68 node_id: int, 

69 from_date, 

70 to_date, 

71 *, 

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

73 status=None, 

74 same_gender_only: bool = False, 

75) -> int: 

76 with session_scope() as session: 

77 trip = PublicTrip( 

78 user_id=user_id, 

79 node_id=node_id, 

80 from_date=from_date, 

81 to_date=to_date, 

82 description=description, 

83 status=status or PublicTripStatus.searching_for_host, 

84 same_gender_only=same_gender_only, 

85 ) 

86 session.add(trip) 

87 session.flush() 

88 return trip.id 

89 

90 

91def test_create_public_trip(db): 

92 user, token = generate_user() 

93 node_id = _make_node() 

94 

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

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

97 

98 with public_trips_session(token) as api: 

99 res = api.CreatePublicTrip( 

100 public_trips_pb2.CreatePublicTripReq( 

101 community_id=node_id, 

102 from_date=from_date.isoformat(), 

103 to_date=to_date.isoformat(), 

104 description=VALID_DESCRIPTION, 

105 ) 

106 ) 

107 

108 assert res.trip_id > 0 

109 assert res.user.user_id == user.id 

110 assert res.community_id == node_id 

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

112 assert res.community_name == "Test community" 

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

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

115 assert res.description == VALID_DESCRIPTION 

116 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

117 

118 with session_scope() as session: 

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

120 assert trip.user_id == user.id 

121 assert trip.node_id == node_id 

122 assert trip.status == PublicTripStatus.searching_for_host 

123 

124 

125def test_create_public_trip_incomplete_profile(db): 

126 _, token = generate_user(complete_profile=False) 

127 node_id = _make_node() 

128 

129 with public_trips_session(token) as api: 

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

131 api.CreatePublicTrip( 

132 public_trips_pb2.CreatePublicTripReq( 

133 community_id=node_id, 

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

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

136 description="Visiting town!", 

137 ) 

138 ) 

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

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

141 

142 

143def test_create_public_trip_community_not_found(db): 

144 _, token = generate_user() 

145 

146 with public_trips_session(token) as api: 

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

148 api.CreatePublicTrip( 

149 public_trips_pb2.CreatePublicTripReq( 

150 community_id=999999, 

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

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

153 description="Visiting town!", 

154 ) 

155 ) 

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

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

158 

159 

160def test_create_public_trip_not_enabled(db): 

161 _, token = generate_user() 

162 node_id = _make_node(small_community_features_enabled=False) 

163 

164 with public_trips_session(token) as api: 

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

166 api.CreatePublicTrip( 

167 public_trips_pb2.CreatePublicTripReq( 

168 community_id=node_id, 

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

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

171 description="Visiting town!", 

172 ) 

173 ) 

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

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

176 

177 

178@pytest.mark.parametrize( 

179 "node_type", 

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

181) 

182def test_create_public_trip_allows_region_and_narrower(db, node_type): 

183 _, token = generate_user() 

184 node_id = _make_node(node_type=node_type) 

185 

186 with public_trips_session(token) as api: 

187 res = api.CreatePublicTrip( 

188 public_trips_pb2.CreatePublicTripReq( 

189 community_id=node_id, 

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

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

192 description=VALID_DESCRIPTION, 

193 ) 

194 ) 

195 assert res.trip_id > 0 

196 

197 

198def test_create_public_trip_in_past_uses_node_timezone(db): 

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

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

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

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

203 _, token = generate_user() 

204 node_id = _make_node() 

205 

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

207 

208 with patch( 

209 "couchers.servicers.public_trips.today_in_timezone", 

210 side_effect=lambda tz: fake_today_by_tz[tz], 

211 ): 

212 with public_trips_session(token) as api: 

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

214 api.CreatePublicTrip( 

215 public_trips_pb2.CreatePublicTripReq( 

216 community_id=node_id, 

217 from_date="2026-01-15", 

218 to_date="2026-01-20", 

219 description=VALID_DESCRIPTION, 

220 ) 

221 ) 

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

223 

224 

225def test_create_public_trip_date_errors(db): 

226 _, token = generate_user() 

227 node_id = _make_node() 

228 

229 with public_trips_session(token) as api: 

230 # from_date in the past 

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

232 api.CreatePublicTrip( 

233 public_trips_pb2.CreatePublicTripReq( 

234 community_id=node_id, 

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

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

237 description="Visiting town!", 

238 ) 

239 ) 

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

241 

242 # from_date after to_date 

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

244 api.CreatePublicTrip( 

245 public_trips_pb2.CreatePublicTripReq( 

246 community_id=node_id, 

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

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

249 description="Visiting town!", 

250 ) 

251 ) 

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

253 

254 # empty description 

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

256 api.CreatePublicTrip( 

257 public_trips_pb2.CreatePublicTripReq( 

258 community_id=node_id, 

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

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

261 description=" ", 

262 ) 

263 ) 

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

265 

266 # invalid date format 

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

268 api.CreatePublicTrip( 

269 public_trips_pb2.CreatePublicTripReq( 

270 community_id=node_id, 

271 from_date="not-a-date", 

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

273 description="Visiting town!", 

274 ) 

275 ) 

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

277 

278 

279def test_create_public_trip_overlap(db): 

280 user, token = generate_user() 

281 node_id = _make_node() 

282 

283 _create_trip_directly( 

284 user.id, 

285 node_id, 

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

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

288 ) 

289 

290 with public_trips_session(token) as api: 

291 # overlapping dates should fail 

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

293 api.CreatePublicTrip( 

294 public_trips_pb2.CreatePublicTripReq( 

295 community_id=node_id, 

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

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

298 description=VALID_DESCRIPTION, 

299 ) 

300 ) 

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

302 

303 # non-overlapping dates should succeed 

304 res = api.CreatePublicTrip( 

305 public_trips_pb2.CreatePublicTripReq( 

306 community_id=node_id, 

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

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

309 description=VALID_DESCRIPTION, 

310 ) 

311 ) 

312 assert res.trip_id > 0 

313 

314 

315def test_create_public_trip_closed_trip_allows_new_overlap(db): 

316 user, token = generate_user() 

317 node_id = _make_node() 

318 

319 _create_trip_directly( 

320 user.id, 

321 node_id, 

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

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

324 status=PublicTripStatus.closed, 

325 ) 

326 

327 with public_trips_session(token) as api: 

328 # closed trips shouldn't block new overlapping ones 

329 res = api.CreatePublicTrip( 

330 public_trips_pb2.CreatePublicTripReq( 

331 community_id=node_id, 

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

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

334 description=VALID_DESCRIPTION, 

335 ) 

336 ) 

337 assert res.trip_id > 0 

338 

339 

340def test_get_public_trip(db): 

341 user, token = generate_user() 

342 node_id = _make_node() 

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

344 

345 with public_trips_session(token) as api: 

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

347 assert res.trip_id == trip_id 

348 assert res.user.user_id == user.id 

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

350 assert res.community_name == "Test community" 

351 

352 

353def test_get_public_trip_not_found(db): 

354 _, token = generate_user() 

355 with public_trips_session(token) as api: 

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

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

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

359 

360 

361def test_list_public_trips(db): 

362 traveler, _ = generate_user() 

363 _, host_token = generate_user() 

364 node_id = _make_node() 

365 

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

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

368 

369 with public_trips_session(host_token) as api: 

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

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

372 assert returned_ids == {trip1, trip2} 

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

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

375 

376 

377def test_list_public_trips_filters_closed_and_past(db): 

378 traveler, _ = generate_user() 

379 _, host_token = generate_user() 

380 node_id = _make_node() 

381 

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

383 # closed trip - should be hidden 

384 _create_trip_directly( 

385 traveler.id, 

386 node_id, 

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

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

389 status=PublicTripStatus.closed, 

390 ) 

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

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

393 

394 with public_trips_session(host_token) as api: 

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

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

397 

398 

399def test_list_public_trips_hides_invisible_user(db): 

400 traveler, _ = generate_user() 

401 _, host_token = generate_user() 

402 node_id = _make_node() 

403 

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

405 

406 # soft-delete the traveler 

407 with session_scope() as session: 

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

409 t.deleted_at = now() 

410 

411 with public_trips_session(host_token) as api: 

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

413 assert len(res.public_trips) == 0 

414 

415 

416def test_list_public_trips_pagination(db): 

417 traveler, _ = generate_user() 

418 _, host_token = generate_user() 

419 node_id = _make_node() 

420 

421 trip_ids = [ 

422 _create_trip_directly( 

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

424 ) 

425 for i in range(5) 

426 ] 

427 

428 with public_trips_session(host_token) as api: 

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

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

431 assert res.next_page_token 

432 

433 res2 = api.ListPublicTrips( 

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

435 ) 

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

437 assert res2.next_page_token 

438 

439 res3 = api.ListPublicTrips( 

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

441 ) 

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

443 assert not res3.next_page_token 

444 

445 

446def test_list_public_trips_by_user_self_sees_all(db): 

447 user, token = generate_user() 

448 other, _ = generate_user() 

449 node_id = _make_node() 

450 

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

452 mine_closed = _create_trip_directly( 

453 user.id, 

454 node_id, 

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

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

457 status=PublicTripStatus.closed, 

458 ) 

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

460 # other user's trip should not be returned 

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

462 

463 with public_trips_session(token) as api: 

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

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

466 

467 

468def test_list_public_trips_by_user_other_filters_inactive_and_past(db): 

469 traveler, _ = generate_user() 

470 _, viewer_token = generate_user() 

471 node_id = _make_node() 

472 

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

474 # closed trip - hidden from others 

475 _create_trip_directly( 

476 traveler.id, 

477 node_id, 

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

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

480 status=PublicTripStatus.closed, 

481 ) 

482 # past trip - hidden from others 

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

484 

485 with public_trips_session(viewer_token) as api: 

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

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

488 

489 

490def test_list_public_trips_by_user_invisible_user(db): 

491 traveler, _ = generate_user() 

492 _, viewer_token = generate_user() 

493 node_id = _make_node() 

494 

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

496 

497 # soft-delete the traveler 

498 with session_scope() as session: 

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

500 t.deleted_at = now() 

501 

502 with public_trips_session(viewer_token) as api: 

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

504 assert len(res.public_trips) == 0 

505 

506 

507def test_update_public_trip_close(db): 

508 user, token = generate_user() 

509 node_id = _make_node() 

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

511 

512 with public_trips_session(token) as api: 

513 res = api.UpdatePublicTrip( 

514 public_trips_pb2.UpdatePublicTripReq( 

515 trip_id=trip_id, 

516 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

517 ) 

518 ) 

519 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

520 

521 with session_scope() as session: 

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

523 assert trip.status == PublicTripStatus.closed 

524 

525 

526def test_update_public_trip_reopen(db): 

527 user, token = generate_user() 

528 node_id = _make_node() 

529 trip_id = _create_trip_directly( 

530 user.id, 

531 node_id, 

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

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

534 status=PublicTripStatus.closed, 

535 ) 

536 

537 with public_trips_session(token) as api: 

538 res = api.UpdatePublicTrip( 

539 public_trips_pb2.UpdatePublicTripReq( 

540 trip_id=trip_id, 

541 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

542 ) 

543 ) 

544 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST 

545 

546 with session_scope() as session: 

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

548 assert trip.status == PublicTripStatus.searching_for_host 

549 

550 

551def test_update_public_trip_cant_reopen_past_trip(db): 

552 user, token = generate_user() 

553 node_id = _make_node() 

554 trip_id = _create_trip_directly( 

555 user.id, 

556 node_id, 

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

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

559 status=PublicTripStatus.closed, 

560 ) 

561 

562 with public_trips_session(token) as api: 

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

564 api.UpdatePublicTrip( 

565 public_trips_pb2.UpdatePublicTripReq( 

566 trip_id=trip_id, 

567 status=public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST, 

568 ) 

569 ) 

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

571 

572 

573def test_update_public_trip_close_past_trip_allowed(db): 

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

575 user, token = generate_user() 

576 node_id = _make_node() 

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

578 

579 with public_trips_session(token) as api: 

580 res = api.UpdatePublicTrip( 

581 public_trips_pb2.UpdatePublicTripReq( 

582 trip_id=trip_id, 

583 status=public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED, 

584 ) 

585 ) 

586 assert res.status == public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED 

587 

588 

589def test_update_public_trip_description_only(db): 

590 user, token = generate_user() 

591 node_id = _make_node() 

592 trip_id = _create_trip_directly( 

593 user.id, 

594 node_id, 

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

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

597 description="Original description", 

598 ) 

599 

600 updated = VALID_DESCRIPTION + " Updated plans." 

601 

602 with public_trips_session(token) as api: 

603 res = api.UpdatePublicTrip( 

604 public_trips_pb2.UpdatePublicTripReq( 

605 trip_id=trip_id, 

606 description=updated, 

607 ) 

608 ) 

609 assert res.trip_id == trip_id 

610 assert res.description == updated 

611 # dates should be unchanged 

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

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

614 

615 with session_scope() as session: 

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

617 assert trip.description == updated 

618 

619 

620def test_update_public_trip_dates(db): 

621 user, token = generate_user() 

622 node_id = _make_node() 

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

624 

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

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

627 

628 with public_trips_session(token) as api: 

629 res = api.UpdatePublicTrip( 

630 public_trips_pb2.UpdatePublicTripReq( 

631 trip_id=trip_id, 

632 from_date=new_from.isoformat(), 

633 to_date=new_to.isoformat(), 

634 ) 

635 ) 

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

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

638 

639 

640def test_update_public_trip_not_owner(db): 

641 user, _ = generate_user() 

642 _, other_token = generate_user() 

643 node_id = _make_node() 

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

645 

646 with public_trips_session(other_token) as api: 

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

648 api.UpdatePublicTrip( 

649 public_trips_pb2.UpdatePublicTripReq( 

650 trip_id=trip_id, 

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

652 ) 

653 ) 

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

655 

656 

657def test_update_public_trip_in_past(db): 

658 user, token = generate_user() 

659 node_id = _make_node() 

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

661 

662 with public_trips_session(token) as api: 

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

664 api.UpdatePublicTrip( 

665 public_trips_pb2.UpdatePublicTripReq( 

666 trip_id=trip_id, 

667 description="Too late!", 

668 ) 

669 ) 

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

671 

672 

673def test_update_public_trip_date_validation(db): 

674 user, token = generate_user() 

675 node_id = _make_node() 

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

677 

678 with public_trips_session(token) as api: 

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

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

681 api.UpdatePublicTrip( 

682 public_trips_pb2.UpdatePublicTripReq( 

683 trip_id=trip_id, 

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

685 ) 

686 ) 

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

688 

689 # Empty description 

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

691 api.UpdatePublicTrip( 

692 public_trips_pb2.UpdatePublicTripReq( 

693 trip_id=trip_id, 

694 description=" ", 

695 ) 

696 ) 

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

698 

699 

700def test_create_public_trip_description_too_short(db): 

701 _, token = generate_user() 

702 node_id = _make_node() 

703 

704 with public_trips_session(token) as api: 

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

706 api.CreatePublicTrip( 

707 public_trips_pb2.CreatePublicTripReq( 

708 community_id=node_id, 

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

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

711 description="Too short.", 

712 ) 

713 ) 

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

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

716 

717 

718def test_update_public_trip_description_too_short(db): 

719 user, token = generate_user() 

720 node_id = _make_node() 

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

722 

723 with public_trips_session(token) as api: 

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

725 api.UpdatePublicTrip( 

726 public_trips_pb2.UpdatePublicTripReq( 

727 trip_id=trip_id, 

728 description="Too short.", 

729 ) 

730 ) 

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

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

733 

734 

735def test_same_gender_only_create_and_retrieve(db): 

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

737 node_id = _make_node() 

738 

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

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

741 

742 with public_trips_session(token) as api: 

743 res = api.CreatePublicTrip( 

744 public_trips_pb2.CreatePublicTripReq( 

745 community_id=node_id, 

746 from_date=from_date.isoformat(), 

747 to_date=to_date.isoformat(), 

748 description=VALID_DESCRIPTION, 

749 same_gender_only=True, 

750 ) 

751 ) 

752 assert res.same_gender_only is True 

753 

754 with session_scope() as session: 

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

756 assert trip.same_gender_only is True 

757 

758 

759def test_same_gender_only_visibility_list_and_get(db): 

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

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

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

763 node_id = _make_node() 

764 

765 filtered_trip_id = _create_trip_directly( 

766 traveler.id, 

767 node_id, 

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

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

770 same_gender_only=True, 

771 ) 

772 open_trip_id = _create_trip_directly( 

773 traveler.id, 

774 node_id, 

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

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

777 ) 

778 

779 with public_trips_session(same_gender_token) as api: 

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

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

782 

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

784 assert get_res.trip_id == filtered_trip_id 

785 assert get_res.same_gender_only is True 

786 

787 with public_trips_session(diff_gender_token) as api: 

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

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

790 

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

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

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

794 

795 

796def test_same_gender_only_moderator_bypass(db): 

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

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

799 node_id = _make_node() 

800 _make_node_admin(mod.id, node_id) 

801 

802 trip_id = _create_trip_directly( 

803 traveler.id, 

804 node_id, 

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

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

807 same_gender_only=True, 

808 ) 

809 

810 with public_trips_session(mod_token) as api: 

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

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

813 

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

815 assert get_res.trip_id == trip_id 

816 

817 

818def test_same_gender_only_owner_always_sees_own_trips(db): 

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

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

821 node_id = _make_node() 

822 

823 trip_id = _create_trip_directly( 

824 traveler.id, 

825 node_id, 

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

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

828 same_gender_only=True, 

829 ) 

830 

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

832 with public_trips_session(traveler_token) as api: 

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

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

835 

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

837 with public_trips_session(diff_gender_token) as api: 

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

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

840 

841 

842def test_same_gender_only_update(db): 

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

844 node_id = _make_node() 

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

846 

847 with public_trips_session(token) as api: 

848 res = api.UpdatePublicTrip( 

849 public_trips_pb2.UpdatePublicTripReq( 

850 trip_id=trip_id, 

851 same_gender_only=True, 

852 ) 

853 ) 

854 assert res.same_gender_only is True 

855 

856 res = api.UpdatePublicTrip( 

857 public_trips_pb2.UpdatePublicTripReq( 

858 trip_id=trip_id, 

859 same_gender_only=False, 

860 ) 

861 ) 

862 assert res.same_gender_only is False 

863 

864 with session_scope() as session: 

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

866 assert trip.same_gender_only is False 

867 

868 

869def test_list_public_trips_by_user_ascending_order(db): 

870 user, token = generate_user() 

871 node_id = _make_node() 

872 

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

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

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

876 

877 with public_trips_session(token) as api: 

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

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

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

881 

882 res_desc = api.ListPublicTripsByUser( 

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

884 ) 

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

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

887 

888 

889def test_list_public_trips_by_user_status_filter(db): 

890 user, token = generate_user() 

891 node_id = _make_node() 

892 

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

894 closed = _create_trip_directly( 

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

896 ) 

897 

898 with public_trips_session(token) as api: 

899 # Filter to active only 

900 res = api.ListPublicTripsByUser( 

901 public_trips_pb2.ListPublicTripsByUserReq( 

902 user_id=user.id, 

903 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_SEARCHING_FOR_HOST], 

904 ) 

905 ) 

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

907 

908 # Filter to closed only 

909 res = api.ListPublicTripsByUser( 

910 public_trips_pb2.ListPublicTripsByUserReq( 

911 user_id=user.id, 

912 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

913 ) 

914 ) 

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

916 

917 # No filter returns all 

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

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

920 

921 

922def test_list_public_trips_by_user_status_filter_ignored_for_others(db): 

923 traveler, _ = generate_user() 

924 _, viewer_token = generate_user() 

925 node_id = _make_node() 

926 

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

928 _create_trip_directly( 

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

930 ) 

931 

932 with public_trips_session(viewer_token) as api: 

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

934 res = api.ListPublicTripsByUser( 

935 public_trips_pb2.ListPublicTripsByUserReq( 

936 user_id=traveler.id, 

937 statuses_in=[public_trips_pb2.PUBLIC_TRIP_STATUS_CLOSED], 

938 ) 

939 ) 

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

941 

942 

943def test_list_public_trips_by_user_offers_count_owner(db): 

944 traveler, traveler_token = generate_user() 

945 host, host_token = generate_user() 

946 node_id = _make_node() 

947 

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

949 

950 with public_trips_session(traveler_token) as api: 

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

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

953 assert trip.HasField("offers_count") 

954 assert trip.offers_count == 0 

955 

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

957 with requests_session(host_token) as api: 

958 api.CreateHostRequest( 

959 requests_pb2.CreateHostRequestReq( 

960 host_user_id=traveler.id, 

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

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

963 text=_valid_request_text(), 

964 public_trip_id=trip_id, 

965 ) 

966 ) 

967 

968 with public_trips_session(traveler_token) as api: 

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

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

971 assert trip.HasField("offers_count") 

972 assert trip.offers_count == 1 

973 

974 

975def test_list_public_trips_by_user_offers_count_not_set_for_others(db): 

976 traveler, _ = generate_user() 

977 _, viewer_token = generate_user() 

978 node_id = _make_node() 

979 

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

981 

982 with public_trips_session(viewer_token) as api: 

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

984 assert len(res.public_trips) == 1 

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

986 

987 

988def test_viewer_host_request_id_reflects_viewers_own_offer(db): 

989 traveler, _ = generate_user() 

990 host, host_token = generate_user() 

991 _, other_token = generate_user() 

992 node_id = _make_node() 

993 

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

995 

996 # Before offering, the host sees 0. 

997 with public_trips_session(host_token) as api: 

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

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

1000 assert trip.viewer_host_request_id == 0 

1001 

1002 # The host makes an offer on the trip. 

1003 with requests_session(host_token) as api: 

1004 create_res = api.CreateHostRequest( 

1005 requests_pb2.CreateHostRequestReq( 

1006 host_user_id=traveler.id, 

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

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

1009 text=_valid_request_text(), 

1010 public_trip_id=trip_id, 

1011 ) 

1012 ) 

1013 host_request_id = create_res.host_request_id 

1014 

1015 with public_trips_session(host_token) as api: 

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

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

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

1019 assert trip.viewer_host_request_id == host_request_id 

1020 

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

1022 assert get_res.viewer_host_request_id == host_request_id 

1023 

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

1025 with public_trips_session(other_token) as api: 

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

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

1028 assert trip.viewer_host_request_id == 0