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
« 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
5import grpc
6import pytest
7from sqlalchemy import select
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
29@pytest.fixture(autouse=True)
30def _(testconfig):
31 pass
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)
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
52def test_postal_verification_disabled(db):
53 """Test that postal verification is disabled by default."""
54 user, token = generate_user()
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
70def test_postal_verification_happy_path(db, monkeypatch):
71 """Test the complete happy path for postal verification."""
72 _monkeypatch_postal_verification_config(monkeypatch)
74 user, token = generate_user()
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
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
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
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 )
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
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
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
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
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
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
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)
155def test_postal_verification_wrong_code(db, monkeypatch):
156 """Test entering wrong verification codes."""
157 _monkeypatch_postal_verification_config(monkeypatch)
159 user, token = generate_user()
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
174 with postal_verification_session(token) as pv:
175 pv.ConfirmPostalAddress(
176 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
177 )
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
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
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
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
203def test_postal_verification_code_expiry(db, monkeypatch):
204 """Test that codes expire after the configured lifetime."""
205 _monkeypatch_postal_verification_config(monkeypatch)
207 user, token = generate_user()
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
222 with postal_verification_session(token) as pv:
223 pv.ConfirmPostalAddress(
224 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
225 )
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
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)
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
249def test_postal_verification_rate_limit(db, monkeypatch):
250 """Test rate limiting on postal verification attempts."""
251 _monkeypatch_postal_verification_config(monkeypatch)
253 user, token = generate_user()
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
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 )
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
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
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)
299 user, token = generate_user()
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 )
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
328def test_postal_verification_cancel(db, monkeypatch):
329 """Test cancelling a postal verification attempt."""
330 _monkeypatch_postal_verification_config(monkeypatch)
332 user, token = generate_user()
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
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 )
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
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)
364 user, token = generate_user()
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
379 with postal_verification_session(token) as pv:
380 pv.ConfirmPostalAddress(
381 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
382 )
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
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
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 )
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
408def test_postal_verification_list_attempts(db, monkeypatch):
409 """Test listing postal verification attempts."""
410 _monkeypatch_postal_verification_config(monkeypatch)
412 user, token = generate_user()
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
427 with postal_verification_session(token) as pv:
428 pv.CancelPostalVerification(
429 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id_1)
430 )
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)
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
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
461def test_postal_verification_address_validation(db, monkeypatch):
462 """Test address validation errors."""
463 _monkeypatch_postal_verification_config(monkeypatch)
465 user, token = generate_user()
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
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
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
506def test_postal_verification_postcard_send_failure(db, monkeypatch):
507 """Test handling of postcard send failure."""
508 _monkeypatch_postal_verification_config(monkeypatch)
510 user, token = generate_user()
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
525 with postal_verification_session(token) as pv:
526 pv.ConfirmPostalAddress(
527 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
528 )
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()
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
542def test_postal_verification_code_case_insensitive(db, monkeypatch):
543 """Test that verification codes are case insensitive."""
544 _monkeypatch_postal_verification_config(monkeypatch)
546 user, token = generate_user()
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
561 with postal_verification_session(token) as pv:
562 pv.ConfirmPostalAddress(
563 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
564 )
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
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
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
586def test_postal_verification_attempt_not_found(db, monkeypatch):
587 """Test accessing non-existent attempts."""
588 _monkeypatch_postal_verification_config(monkeypatch)
590 user, token = generate_user()
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
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
608def test_postal_verification_other_user_attempt(db, monkeypatch):
609 """Test that users cannot access other users' attempts."""
610 _monkeypatch_postal_verification_config(monkeypatch)
612 user1, token1 = generate_user()
613 user2, token2 = generate_user()
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
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
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
645def test_has_postal_verification_helper(db, monkeypatch):
646 """Test the has_postal_verification helper function."""
647 _monkeypatch_postal_verification_config(monkeypatch)
649 user, token = generate_user()
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)
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
669 with postal_verification_session(token) as pv:
670 pv.ConfirmPostalAddress(
671 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
672 )
674 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
675 mock_send.return_value = 12345
676 while process_job():
677 pass
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
685 with postal_verification_session(token) as pv:
686 pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code))
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)
694def test_generate_postcard_images():
695 """
696 Generates sample postcard front and back images for visual inspection.
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)
704 assert len(front) > 0
705 assert len(back) > 0
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)