Coverage for app / backend / src / tests / test_postal_verification.py: 100%

310 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 11:04 +0000

1from datetime import timedelta 

2from pathlib import Path 

3from unittest.mock import patch 

4 

5import grpc 

6import pytest 

7from sqlalchemy import select 

8 

9import couchers.servicers.postal_verification 

10from couchers.config import config 

11from couchers.constants import ( 

12 POSTAL_VERIFICATION_CODE_LIFETIME, 

13 POSTAL_VERIFICATION_MAX_ATTEMPTS, 

14 POSTAL_VERIFICATION_RATE_LIMIT, 

15) 

16from couchers.db import session_scope 

17from couchers.helpers.postal_verification import generate_postal_verification_code, has_postal_verification 

18from couchers.jobs.worker import process_job 

19from couchers.models import User 

20from couchers.models.postal_verification import PostalVerificationAttempt 

21from couchers.postal.my_postcard import _generate_back_left_side_png 

22from couchers.proto import postal_verification_pb2 

23from couchers.resources import get_postcard_front_image 

24from couchers.utils import now 

25from tests.fixtures.db import generate_user 

26from tests.fixtures.sessions import postal_verification_session 

27 

28 

29@pytest.fixture(autouse=True) 

30def _(testconfig): 

31 pass 

32 

33 

34def _monkeypatch_postal_verification_config(monkeypatch): 

35 new_config = config.copy() 

36 new_config["ENABLE_POSTAL_VERIFICATION"] = True 

37 monkeypatch.setattr(couchers.servicers.postal_verification, "config", new_config) 

38 

39 

40def test_generate_postal_verification_code(): 

41 """Test that generated codes meet requirements.""" 

42 allowed = set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") 

43 for _ in range(100): 

44 code = generate_postal_verification_code() 

45 assert len(code) == 6 

46 assert all(c in allowed for c in code) 

47 # Should not contain confusing characters 

48 for char in "IO01": 

49 assert char not in code 

50 

51 

52def test_postal_verification_disabled(db): 

53 """Test that postal verification is disabled by default.""" 

54 user, token = generate_user() 

55 

56 with postal_verification_session(token) as pv: 

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

58 pv.InitiatePostalVerification( 

59 postal_verification_pb2.InitiatePostalVerificationReq( 

60 address=postal_verification_pb2.PostalAddress( 

61 address_line_1="123 Main St", 

62 city="Test City", 

63 country_code="US", 

64 ) 

65 ) 

66 ) 

67 assert e.value.code() == grpc.StatusCode.UNAVAILABLE 

68 

69 

70def test_postal_verification_happy_path(db, monkeypatch): 

71 """Test the complete happy path for postal verification.""" 

72 _monkeypatch_postal_verification_config(monkeypatch) 

73 

74 user, token = generate_user() 

75 

76 # Check initial status 

77 with postal_verification_session(token) as pv: 

78 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

79 assert not status.has_postal_verification 

80 assert status.can_initiate_new_attempt 

81 assert not status.has_active_attempt 

82 

83 # Step 1: Initiate postal verification 

84 with postal_verification_session(token) as pv: 

85 res = pv.InitiatePostalVerification( 

86 postal_verification_pb2.InitiatePostalVerificationReq( 

87 address=postal_verification_pb2.PostalAddress( 

88 address_line_1="123 Main St", 

89 address_line_2="Apt 4", 

90 city="Test City", 

91 state="CA", 

92 postal_code="12345", 

93 country_code="US", 

94 ) 

95 ) 

96 ) 

97 attempt_id = res.postal_verification_attempt_id 

98 assert attempt_id > 0 

99 

100 # Check status after initiation 

101 with postal_verification_session(token) as pv: 

102 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

103 assert not status.has_postal_verification 

104 assert not status.can_initiate_new_attempt 

105 assert status.has_active_attempt 

106 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_PENDING_ADDRESS_CONFIRMATION 

107 

108 # Step 2: Confirm address 

109 with postal_verification_session(token) as pv: 

110 pv.ConfirmPostalAddress( 

111 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

112 ) 

113 

114 # Check status after confirmation 

115 with postal_verification_session(token) as pv: 

116 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

117 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_IN_PROGRESS 

118 

119 # Process background job to send postcard 

120 with patch("couchers.jobs.handlers.send_postcard") as mock_send: 

121 mock_send.return_value = 12345 

122 while process_job(): 

123 pass 

124 

125 # Check status after postcard sent 

126 with postal_verification_session(token) as pv: 

127 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

128 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_AWAITING_VERIFICATION 

129 assert status.postcard_sent_at.seconds > 0 

130 

131 # Get the verification code from the database 

132 with session_scope() as session: 

133 attempt = session.execute( 

134 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id) 

135 ).scalar_one() 

136 verification_code = attempt.verification_code 

137 

138 # Step 3: Verify the code 

139 with postal_verification_session(token) as pv: 

140 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code)) 

141 assert res.success 

142 

143 # Check final status 

144 with postal_verification_session(token) as pv: 

145 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

146 assert status.has_postal_verification 

147 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_SUCCEEDED 

148 

149 # Verify with helper function 

150 with session_scope() as session: 

151 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one() 

152 assert has_postal_verification(session, db_user) 

153 

154 

155def test_postal_verification_wrong_code(db, monkeypatch): 

156 """Test entering wrong verification codes.""" 

157 _monkeypatch_postal_verification_config(monkeypatch) 

158 

159 user, token = generate_user() 

160 

161 # Initiate and confirm 

162 with postal_verification_session(token) as pv: 

163 res = pv.InitiatePostalVerification( 

164 postal_verification_pb2.InitiatePostalVerificationReq( 

165 address=postal_verification_pb2.PostalAddress( 

166 address_line_1="123 Main St", 

167 city="Test City", 

168 country_code="US", 

169 ) 

170 ) 

171 ) 

172 attempt_id = res.postal_verification_attempt_id 

173 

174 with postal_verification_session(token) as pv: 

175 pv.ConfirmPostalAddress( 

176 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

177 ) 

178 

179 # Process background job 

180 with patch("couchers.jobs.handlers.send_postcard") as mock_send: 

181 mock_send.return_value = 12345 

182 while process_job(): 

183 pass 

184 

185 # Try wrong codes 

186 with postal_verification_session(token) as pv: 

187 for i in range(POSTAL_VERIFICATION_MAX_ATTEMPTS - 1): 

188 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code="WRONGX")) 

189 assert not res.success 

190 assert res.remaining_attempts == POSTAL_VERIFICATION_MAX_ATTEMPTS - 1 - i 

191 

192 # Last attempt should fail and lock the attempt 

193 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code="WRONGX")) 

194 assert not res.success 

195 assert res.remaining_attempts == 0 

196 

197 # Check status is failed 

198 with postal_verification_session(token) as pv: 

199 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

200 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_FAILED 

201 

202 

203def test_postal_verification_code_expiry(db, monkeypatch): 

204 """Test that codes expire after the configured lifetime.""" 

205 _monkeypatch_postal_verification_config(monkeypatch) 

206 

207 user, token = generate_user() 

208 

209 # Initiate and confirm 

210 with postal_verification_session(token) as pv: 

211 res = pv.InitiatePostalVerification( 

212 postal_verification_pb2.InitiatePostalVerificationReq( 

213 address=postal_verification_pb2.PostalAddress( 

214 address_line_1="123 Main St", 

215 city="Test City", 

216 country_code="US", 

217 ) 

218 ) 

219 ) 

220 attempt_id = res.postal_verification_attempt_id 

221 

222 with postal_verification_session(token) as pv: 

223 pv.ConfirmPostalAddress( 

224 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

225 ) 

226 

227 # Process background job 

228 with patch("couchers.jobs.handlers.send_postcard") as mock_send: 

229 mock_send.return_value = 12345 

230 while process_job(): 

231 pass 

232 

233 # Get the code 

234 with session_scope() as session: 

235 attempt = session.execute( 

236 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id) 

237 ).scalar_one() 

238 verification_code = attempt.verification_code 

239 # Set postcard_sent_at to be past expiry 

240 attempt.postcard_sent_at = now() - POSTAL_VERIFICATION_CODE_LIFETIME - timedelta(days=1) 

241 

242 # Try to verify - should fail due to expiry 

243 with postal_verification_session(token) as pv: 

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

245 pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code)) 

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

247 

248 

249def test_postal_verification_rate_limit(db, monkeypatch): 

250 """Test rate limiting on postal verification attempts.""" 

251 _monkeypatch_postal_verification_config(monkeypatch) 

252 

253 user, token = generate_user() 

254 

255 # First attempt 

256 with postal_verification_session(token) as pv: 

257 res = pv.InitiatePostalVerification( 

258 postal_verification_pb2.InitiatePostalVerificationReq( 

259 address=postal_verification_pb2.PostalAddress( 

260 address_line_1="123 Main St", 

261 city="Test City", 

262 country_code="US", 

263 ) 

264 ) 

265 ) 

266 attempt_id = res.postal_verification_attempt_id 

267 

268 # Cancel the first attempt 

269 with postal_verification_session(token) as pv: 

270 pv.CancelPostalVerification( 

271 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id) 

272 ) 

273 

274 # Try to initiate again immediately - should be rate limited 

275 with postal_verification_session(token) as pv: 

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

277 pv.InitiatePostalVerification( 

278 postal_verification_pb2.InitiatePostalVerificationReq( 

279 address=postal_verification_pb2.PostalAddress( 

280 address_line_1="456 Other St", 

281 city="Test City", 

282 country_code="US", 

283 ) 

284 ) 

285 ) 

286 assert e.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED 

287 

288 # Check status shows rate limit info 

289 with postal_verification_session(token) as pv: 

290 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

291 assert not status.can_initiate_new_attempt 

292 assert status.next_attempt_allowed_at.seconds > 0 

293 

294 

295def test_postal_verification_already_in_progress(db, monkeypatch): 

296 """Test that you can't start a new attempt while one is in progress.""" 

297 _monkeypatch_postal_verification_config(monkeypatch) 

298 

299 user, token = generate_user() 

300 

301 # First attempt 

302 with postal_verification_session(token) as pv: 

303 pv.InitiatePostalVerification( 

304 postal_verification_pb2.InitiatePostalVerificationReq( 

305 address=postal_verification_pb2.PostalAddress( 

306 address_line_1="123 Main St", 

307 city="Test City", 

308 country_code="US", 

309 ) 

310 ) 

311 ) 

312 

313 # Try to initiate another - should fail 

314 with postal_verification_session(token) as pv: 

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

316 pv.InitiatePostalVerification( 

317 postal_verification_pb2.InitiatePostalVerificationReq( 

318 address=postal_verification_pb2.PostalAddress( 

319 address_line_1="456 Other St", 

320 city="Test City", 

321 country_code="US", 

322 ) 

323 ) 

324 ) 

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

326 

327 

328def test_postal_verification_cancel(db, monkeypatch): 

329 """Test cancelling a postal verification attempt.""" 

330 _monkeypatch_postal_verification_config(monkeypatch) 

331 

332 user, token = generate_user() 

333 

334 # Initiate 

335 with postal_verification_session(token) as pv: 

336 res = pv.InitiatePostalVerification( 

337 postal_verification_pb2.InitiatePostalVerificationReq( 

338 address=postal_verification_pb2.PostalAddress( 

339 address_line_1="123 Main St", 

340 city="Test City", 

341 country_code="US", 

342 ) 

343 ) 

344 ) 

345 attempt_id = res.postal_verification_attempt_id 

346 

347 # Cancel 

348 with postal_verification_session(token) as pv: 

349 pv.CancelPostalVerification( 

350 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id) 

351 ) 

352 

353 # Check status 

354 with postal_verification_session(token) as pv: 

355 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

356 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_CANCELLED 

357 assert not status.has_active_attempt 

358 

359 

360def test_postal_verification_can_cancel_after_postcard_sent(db, monkeypatch): 

361 """Test that you CAN cancel after the postcard is sent (e.g., if postcard is lost).""" 

362 _monkeypatch_postal_verification_config(monkeypatch) 

363 

364 user, token = generate_user() 

365 

366 # Initiate and confirm 

367 with postal_verification_session(token) as pv: 

368 res = pv.InitiatePostalVerification( 

369 postal_verification_pb2.InitiatePostalVerificationReq( 

370 address=postal_verification_pb2.PostalAddress( 

371 address_line_1="123 Main St", 

372 city="Test City", 

373 country_code="US", 

374 ) 

375 ) 

376 ) 

377 attempt_id = res.postal_verification_attempt_id 

378 

379 with postal_verification_session(token) as pv: 

380 pv.ConfirmPostalAddress( 

381 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

382 ) 

383 

384 # Process background job 

385 with patch("couchers.jobs.handlers.send_postcard") as mock_send: 

386 mock_send.return_value = 12345 

387 while process_job(): 

388 pass 

389 

390 # Verify status is awaiting_verification 

391 with postal_verification_session(token) as pv: 

392 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

393 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_AWAITING_VERIFICATION 

394 

395 # Cancel - should succeed (user can cancel if postcard is lost) 

396 with postal_verification_session(token) as pv: 

397 pv.CancelPostalVerification( 

398 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id) 

399 ) 

400 

401 # Verify status is cancelled 

402 with postal_verification_session(token) as pv: 

403 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

404 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_CANCELLED 

405 assert not status.has_active_attempt 

406 

407 

408def test_postal_verification_list_attempts(db, monkeypatch): 

409 """Test listing postal verification attempts.""" 

410 _monkeypatch_postal_verification_config(monkeypatch) 

411 

412 user, token = generate_user() 

413 

414 # Create first attempt and cancel it 

415 with postal_verification_session(token) as pv: 

416 res = pv.InitiatePostalVerification( 

417 postal_verification_pb2.InitiatePostalVerificationReq( 

418 address=postal_verification_pb2.PostalAddress( 

419 address_line_1="123 Main St", 

420 city="Test City", 

421 country_code="US", 

422 ) 

423 ) 

424 ) 

425 attempt_id_1 = res.postal_verification_attempt_id 

426 

427 with postal_verification_session(token) as pv: 

428 pv.CancelPostalVerification( 

429 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id_1) 

430 ) 

431 

432 # Move created time back to bypass rate limit 

433 with session_scope() as session: 

434 attempt = session.execute( 

435 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id_1) 

436 ).scalar_one() 

437 attempt.created = now() - POSTAL_VERIFICATION_RATE_LIMIT - timedelta(days=1) 

438 

439 # Create second attempt 

440 with postal_verification_session(token) as pv: 

441 res = pv.InitiatePostalVerification( 

442 postal_verification_pb2.InitiatePostalVerificationReq( 

443 address=postal_verification_pb2.PostalAddress( 

444 address_line_1="456 Other St", 

445 city="Other City", 

446 country_code="CA", 

447 ) 

448 ) 

449 ) 

450 attempt_id_2 = res.postal_verification_attempt_id 

451 

452 # List attempts 

453 with postal_verification_session(token) as pv: 

454 res = pv.ListPostalVerificationAttempts(postal_verification_pb2.ListPostalVerificationAttemptsReq()) 

455 assert len(res.attempts) == 2 

456 # Most recent first 

457 assert res.attempts[0].postal_verification_attempt_id == attempt_id_2 

458 assert res.attempts[1].postal_verification_attempt_id == attempt_id_1 

459 

460 

461def test_postal_verification_address_validation(db, monkeypatch): 

462 """Test address validation errors.""" 

463 _monkeypatch_postal_verification_config(monkeypatch) 

464 

465 user, token = generate_user() 

466 

467 # Missing required fields 

468 with postal_verification_session(token) as pv: 

469 # Missing address_line_1 

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

471 pv.InitiatePostalVerification( 

472 postal_verification_pb2.InitiatePostalVerificationReq( 

473 address=postal_verification_pb2.PostalAddress( 

474 city="Test City", 

475 country_code="US", 

476 ) 

477 ) 

478 ) 

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

480 

481 # Missing city 

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

483 pv.InitiatePostalVerification( 

484 postal_verification_pb2.InitiatePostalVerificationReq( 

485 address=postal_verification_pb2.PostalAddress( 

486 address_line_1="123 Main St", 

487 country_code="US", 

488 ) 

489 ) 

490 ) 

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

492 

493 # Missing country 

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

495 pv.InitiatePostalVerification( 

496 postal_verification_pb2.InitiatePostalVerificationReq( 

497 address=postal_verification_pb2.PostalAddress( 

498 address_line_1="123 Main St", 

499 city="Test City", 

500 ) 

501 ) 

502 ) 

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

504 

505 

506def test_postal_verification_postcard_send_failure(db, monkeypatch): 

507 """Test handling of postcard send failure.""" 

508 _monkeypatch_postal_verification_config(monkeypatch) 

509 

510 user, token = generate_user() 

511 

512 # Initiate and confirm 

513 with postal_verification_session(token) as pv: 

514 res = pv.InitiatePostalVerification( 

515 postal_verification_pb2.InitiatePostalVerificationReq( 

516 address=postal_verification_pb2.PostalAddress( 

517 address_line_1="123 Main St", 

518 city="Test City", 

519 country_code="US", 

520 ) 

521 ) 

522 ) 

523 attempt_id = res.postal_verification_attempt_id 

524 

525 with postal_verification_session(token) as pv: 

526 pv.ConfirmPostalAddress( 

527 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

528 ) 

529 

530 # Simulate postcard send failure 

531 with patch("couchers.jobs.handlers.send_postcard") as mock_send: 

532 mock_send.side_effect = Exception("API error") 

533 with pytest.raises(Exception, match="API error"): 

534 process_job() 

535 

536 # Attempt should still be in_progress (job failed, not the attempt) 

537 with postal_verification_session(token) as pv: 

538 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq()) 

539 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_IN_PROGRESS 

540 

541 

542def test_postal_verification_code_case_insensitive(db, monkeypatch): 

543 """Test that verification codes are case insensitive.""" 

544 _monkeypatch_postal_verification_config(monkeypatch) 

545 

546 user, token = generate_user() 

547 

548 # Initiate and confirm 

549 with postal_verification_session(token) as pv: 

550 res = pv.InitiatePostalVerification( 

551 postal_verification_pb2.InitiatePostalVerificationReq( 

552 address=postal_verification_pb2.PostalAddress( 

553 address_line_1="123 Main St", 

554 city="Test City", 

555 country_code="US", 

556 ) 

557 ) 

558 ) 

559 attempt_id = res.postal_verification_attempt_id 

560 

561 with postal_verification_session(token) as pv: 

562 pv.ConfirmPostalAddress( 

563 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

564 ) 

565 

566 # Process background job 

567 with patch("couchers.jobs.handlers.send_postcard") as mock_send: 

568 mock_send.return_value = 12345 

569 while process_job(): 

570 pass 

571 

572 # Get the code 

573 with session_scope() as session: 

574 attempt = session.execute( 

575 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id) 

576 ).scalar_one() 

577 verification_code = attempt.verification_code 

578 assert verification_code 

579 

580 # Verify with lowercase code 

581 with postal_verification_session(token) as pv: 

582 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code.lower())) 

583 assert res.success 

584 

585 

586def test_postal_verification_attempt_not_found(db, monkeypatch): 

587 """Test accessing non-existent attempts.""" 

588 _monkeypatch_postal_verification_config(monkeypatch) 

589 

590 user, token = generate_user() 

591 

592 with postal_verification_session(token) as pv: 

593 # Try to confirm non-existent attempt 

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

595 pv.ConfirmPostalAddress( 

596 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=999999) 

597 ) 

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

599 

600 # Try to cancel non-existent attempt 

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

602 pv.CancelPostalVerification( 

603 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=999999) 

604 ) 

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

606 

607 

608def test_postal_verification_other_user_attempt(db, monkeypatch): 

609 """Test that users cannot access other users' attempts.""" 

610 _monkeypatch_postal_verification_config(monkeypatch) 

611 

612 user1, token1 = generate_user() 

613 user2, token2 = generate_user() 

614 

615 # User 1 creates an attempt 

616 with postal_verification_session(token1) as pv: 

617 res = pv.InitiatePostalVerification( 

618 postal_verification_pb2.InitiatePostalVerificationReq( 

619 address=postal_verification_pb2.PostalAddress( 

620 address_line_1="123 Main St", 

621 city="Test City", 

622 country_code="US", 

623 ) 

624 ) 

625 ) 

626 attempt_id = res.postal_verification_attempt_id 

627 

628 # User 2 tries to confirm user 1's attempt 

629 with postal_verification_session(token2) as pv: 

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

631 pv.ConfirmPostalAddress( 

632 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

633 ) 

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

635 

636 # User 2 tries to cancel user 1's attempt 

637 with postal_verification_session(token2) as pv: 

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

639 pv.CancelPostalVerification( 

640 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id) 

641 ) 

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

643 

644 

645def test_has_postal_verification_helper(db, monkeypatch): 

646 """Test the has_postal_verification helper function.""" 

647 _monkeypatch_postal_verification_config(monkeypatch) 

648 

649 user, token = generate_user() 

650 

651 # Initially no verification 

652 with session_scope() as session: 

653 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one() 

654 assert not has_postal_verification(session, db_user) 

655 

656 # Complete verification 

657 with postal_verification_session(token) as pv: 

658 res = pv.InitiatePostalVerification( 

659 postal_verification_pb2.InitiatePostalVerificationReq( 

660 address=postal_verification_pb2.PostalAddress( 

661 address_line_1="123 Main St", 

662 city="Test City", 

663 country_code="US", 

664 ) 

665 ) 

666 ) 

667 attempt_id = res.postal_verification_attempt_id 

668 

669 with postal_verification_session(token) as pv: 

670 pv.ConfirmPostalAddress( 

671 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id) 

672 ) 

673 

674 with patch("couchers.jobs.handlers.send_postcard") as mock_send: 

675 mock_send.return_value = 12345 

676 while process_job(): 

677 pass 

678 

679 with session_scope() as session: 

680 attempt = session.execute( 

681 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id) 

682 ).scalar_one() 

683 verification_code = attempt.verification_code 

684 

685 with postal_verification_session(token) as pv: 

686 pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code)) 

687 

688 # Now should have verification 

689 with session_scope() as session: 

690 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one() 

691 assert has_postal_verification(session, db_user) 

692 

693 

694def test_generate_postcard_images(): 

695 """ 

696 Generates sample postcard front and back images for visual inspection. 

697 

698 Output is written to test_artifacts/ (gitignored) and picked up by CI. 

699 """ 

700 code = "ABC123" 

701 front = get_postcard_front_image() 

702 back = _generate_back_left_side_png(code) 

703 

704 assert len(front) > 0 

705 assert len(back) > 0 

706 

707 output_path = Path(__file__).resolve().parents[2] / "test_artifacts" 

708 output_path.mkdir(parents=True, exist_ok=True) 

709 (output_path / "postcard_front.png").write_bytes(front) 

710 (output_path / "postcard_back.png").write_bytes(back)