Coverage for app/backend/src/tests/test_postal_verification.py: 100%
303 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1from datetime import timedelta
2from pathlib import Path
3from unittest.mock import patch
5import grpc
6import pytest
7from sqlalchemy import select
9from couchers.constants import (
10 POSTAL_VERIFICATION_CODE_LIFETIME,
11 POSTAL_VERIFICATION_MAX_ATTEMPTS,
12 POSTAL_VERIFICATION_RATE_LIMIT,
13)
14from couchers.db import session_scope
15from couchers.helpers.postal_verification import generate_postal_verification_code, has_postal_verification
16from couchers.jobs.worker import process_job
17from couchers.models import User
18from couchers.models.postal_verification import PostalVerificationAttempt, PostalVerificationStatus
19from couchers.postal.my_postcard import _generate_back_left_side_png
20from couchers.proto import postal_verification_pb2
21from couchers.resources import get_postcard_front_image
22from couchers.utils import now
23from tests.fixtures.db import generate_user
24from tests.fixtures.sessions import postal_verification_session
27@pytest.fixture(autouse=True)
28def _(testconfig):
29 pass
32def test_generate_postal_verification_code():
33 """Test that generated codes meet requirements."""
34 allowed = set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
35 for _ in range(100):
36 code = generate_postal_verification_code()
37 assert len(code) == 6
38 assert all(c in allowed for c in code)
39 # Should not contain confusing characters
40 for char in "IO01":
41 assert char not in code
44def test_postal_verification_disabled(db, feature_flags):
45 """Test that postal verification is disabled."""
46 feature_flags.set("postal_verification_enabled", False)
47 user, token = generate_user()
49 with postal_verification_session(token) as pv:
50 with pytest.raises(grpc.RpcError) as e:
51 pv.InitiatePostalVerification(
52 postal_verification_pb2.InitiatePostalVerificationReq(
53 address=postal_verification_pb2.PostalAddress(
54 address_line_1="123 Main St",
55 city="Test City",
56 country_code="US",
57 )
58 )
59 )
60 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
63def test_postal_verification_confirm_disabled(db, feature_flags):
64 """Confirming (which queues the paid postcard) must respect the flag, not just initiation."""
65 feature_flags.set("postal_verification_enabled", False)
66 user, token = generate_user()
68 # Seed a pending attempt directly, since initiation is gated by the same flag.
69 with session_scope() as session:
70 attempt = PostalVerificationAttempt(
71 user_id=user.id,
72 status=PostalVerificationStatus.pending_address_confirmation,
73 address_line_1="123 Main St",
74 city="Test City",
75 country_code="US",
76 )
77 session.add(attempt)
78 session.flush()
79 attempt_id = attempt.id
81 with postal_verification_session(token) as pv:
82 with pytest.raises(grpc.RpcError) as e:
83 pv.ConfirmPostalAddress(
84 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
85 )
86 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
89def test_postal_verification_happy_path(db):
90 """Test the complete happy path for postal verification."""
91 user, token = generate_user()
93 # Check initial status
94 with postal_verification_session(token) as pv:
95 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
96 assert not status.has_postal_verification
97 assert status.can_initiate_new_attempt
98 assert not status.has_active_attempt
100 # Step 1: Initiate postal verification
101 with postal_verification_session(token) as pv:
102 res = pv.InitiatePostalVerification(
103 postal_verification_pb2.InitiatePostalVerificationReq(
104 address=postal_verification_pb2.PostalAddress(
105 address_line_1="123 Main St",
106 address_line_2="Apt 4",
107 city="Test City",
108 state="CA",
109 postal_code="12345",
110 country_code="US",
111 )
112 )
113 )
114 attempt_id = res.postal_verification_attempt_id
115 assert attempt_id > 0
117 # Check status after initiation
118 with postal_verification_session(token) as pv:
119 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
120 assert not status.has_postal_verification
121 assert not status.can_initiate_new_attempt
122 assert status.has_active_attempt
123 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_PENDING_ADDRESS_CONFIRMATION
125 # Step 2: Confirm address
126 with postal_verification_session(token) as pv:
127 pv.ConfirmPostalAddress(
128 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
129 )
131 # Check status after confirmation
132 with postal_verification_session(token) as pv:
133 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
134 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_IN_PROGRESS
136 # Process background job to send postcard
137 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
138 mock_send.return_value = 12345
139 while process_job():
140 pass
142 # Check status after postcard sent
143 with postal_verification_session(token) as pv:
144 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
145 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_AWAITING_VERIFICATION
146 assert status.postcard_sent_at.seconds > 0
148 # Get the verification code from the database
149 with session_scope() as session:
150 attempt = session.execute(
151 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id)
152 ).scalar_one()
153 verification_code = attempt.verification_code
155 # Step 3: Verify the code
156 with postal_verification_session(token) as pv:
157 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code))
158 assert res.success
160 # Check final status
161 with postal_verification_session(token) as pv:
162 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
163 assert status.has_postal_verification
164 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_SUCCEEDED
166 # Verify with helper function
167 with session_scope() as session:
168 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one()
169 assert has_postal_verification(session, db_user)
172def test_postal_verification_wrong_code(db):
173 """Test entering wrong verification codes."""
174 user, token = generate_user()
176 # Initiate and confirm
177 with postal_verification_session(token) as pv:
178 res = pv.InitiatePostalVerification(
179 postal_verification_pb2.InitiatePostalVerificationReq(
180 address=postal_verification_pb2.PostalAddress(
181 address_line_1="123 Main St",
182 city="Test City",
183 country_code="US",
184 )
185 )
186 )
187 attempt_id = res.postal_verification_attempt_id
189 with postal_verification_session(token) as pv:
190 pv.ConfirmPostalAddress(
191 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
192 )
194 # Process background job
195 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
196 mock_send.return_value = 12345
197 while process_job():
198 pass
200 # Try wrong codes
201 with postal_verification_session(token) as pv:
202 for i in range(POSTAL_VERIFICATION_MAX_ATTEMPTS - 1):
203 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code="WRONGX"))
204 assert not res.success
205 assert res.remaining_attempts == POSTAL_VERIFICATION_MAX_ATTEMPTS - 1 - i
207 # Last attempt should fail and lock the attempt
208 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code="WRONGX"))
209 assert not res.success
210 assert res.remaining_attempts == 0
212 # Check status is failed
213 with postal_verification_session(token) as pv:
214 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
215 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_FAILED
218def test_postal_verification_code_expiry(db):
219 """Test that codes expire after the configured lifetime."""
220 user, token = generate_user()
222 # Initiate and confirm
223 with postal_verification_session(token) as pv:
224 res = pv.InitiatePostalVerification(
225 postal_verification_pb2.InitiatePostalVerificationReq(
226 address=postal_verification_pb2.PostalAddress(
227 address_line_1="123 Main St",
228 city="Test City",
229 country_code="US",
230 )
231 )
232 )
233 attempt_id = res.postal_verification_attempt_id
235 with postal_verification_session(token) as pv:
236 pv.ConfirmPostalAddress(
237 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
238 )
240 # Process background job
241 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
242 mock_send.return_value = 12345
243 while process_job():
244 pass
246 # Get the code
247 with session_scope() as session:
248 attempt = session.execute(
249 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id)
250 ).scalar_one()
251 verification_code = attempt.verification_code
252 # Set postcard_sent_at to be past expiry
253 attempt.postcard_sent_at = now() - POSTAL_VERIFICATION_CODE_LIFETIME - timedelta(days=1)
255 # Try to verify - should fail due to expiry
256 with postal_verification_session(token) as pv:
257 with pytest.raises(grpc.RpcError) as e:
258 pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code))
259 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
262def test_postal_verification_rate_limit(db):
263 """Test rate limiting on postal verification attempts."""
264 user, token = generate_user()
266 # First attempt
267 with postal_verification_session(token) as pv:
268 res = pv.InitiatePostalVerification(
269 postal_verification_pb2.InitiatePostalVerificationReq(
270 address=postal_verification_pb2.PostalAddress(
271 address_line_1="123 Main St",
272 city="Test City",
273 country_code="US",
274 )
275 )
276 )
277 attempt_id = res.postal_verification_attempt_id
279 # Cancel the first attempt
280 with postal_verification_session(token) as pv:
281 pv.CancelPostalVerification(
282 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id)
283 )
285 # Try to initiate again immediately - should be rate limited
286 with postal_verification_session(token) as pv:
287 with pytest.raises(grpc.RpcError) as e:
288 pv.InitiatePostalVerification(
289 postal_verification_pb2.InitiatePostalVerificationReq(
290 address=postal_verification_pb2.PostalAddress(
291 address_line_1="456 Other St",
292 city="Test City",
293 country_code="US",
294 )
295 )
296 )
297 assert e.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
299 # Check status shows rate limit info
300 with postal_verification_session(token) as pv:
301 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
302 assert not status.can_initiate_new_attempt
303 assert status.next_attempt_allowed_at.seconds > 0
306def test_postal_verification_already_in_progress(db):
307 """Test that you can't start a new attempt while one is in progress."""
308 user, token = generate_user()
310 # First attempt
311 with postal_verification_session(token) as pv:
312 pv.InitiatePostalVerification(
313 postal_verification_pb2.InitiatePostalVerificationReq(
314 address=postal_verification_pb2.PostalAddress(
315 address_line_1="123 Main St",
316 city="Test City",
317 country_code="US",
318 )
319 )
320 )
322 # Try to initiate another - should fail
323 with postal_verification_session(token) as pv:
324 with pytest.raises(grpc.RpcError) as e:
325 pv.InitiatePostalVerification(
326 postal_verification_pb2.InitiatePostalVerificationReq(
327 address=postal_verification_pb2.PostalAddress(
328 address_line_1="456 Other St",
329 city="Test City",
330 country_code="US",
331 )
332 )
333 )
334 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
337def test_postal_verification_cancel(db):
338 """Test cancelling a postal verification attempt."""
339 user, token = generate_user()
341 # Initiate
342 with postal_verification_session(token) as pv:
343 res = pv.InitiatePostalVerification(
344 postal_verification_pb2.InitiatePostalVerificationReq(
345 address=postal_verification_pb2.PostalAddress(
346 address_line_1="123 Main St",
347 city="Test City",
348 country_code="US",
349 )
350 )
351 )
352 attempt_id = res.postal_verification_attempt_id
354 # Cancel
355 with postal_verification_session(token) as pv:
356 pv.CancelPostalVerification(
357 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id)
358 )
360 # Check status
361 with postal_verification_session(token) as pv:
362 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
363 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_CANCELLED
364 assert not status.has_active_attempt
367def test_postal_verification_can_cancel_after_postcard_sent(db):
368 """Test that you CAN cancel after the postcard is sent (e.g., if postcard is lost)."""
369 user, token = generate_user()
371 # Initiate and confirm
372 with postal_verification_session(token) as pv:
373 res = pv.InitiatePostalVerification(
374 postal_verification_pb2.InitiatePostalVerificationReq(
375 address=postal_verification_pb2.PostalAddress(
376 address_line_1="123 Main St",
377 city="Test City",
378 country_code="US",
379 )
380 )
381 )
382 attempt_id = res.postal_verification_attempt_id
384 with postal_verification_session(token) as pv:
385 pv.ConfirmPostalAddress(
386 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
387 )
389 # Process background job
390 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
391 mock_send.return_value = 12345
392 while process_job():
393 pass
395 # Verify status is awaiting_verification
396 with postal_verification_session(token) as pv:
397 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
398 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_AWAITING_VERIFICATION
400 # Cancel - should succeed (user can cancel if postcard is lost)
401 with postal_verification_session(token) as pv:
402 pv.CancelPostalVerification(
403 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id)
404 )
406 # Verify status is cancelled
407 with postal_verification_session(token) as pv:
408 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
409 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_CANCELLED
410 assert not status.has_active_attempt
413def test_postal_verification_list_attempts(db):
414 """Test listing postal verification attempts."""
415 user, token = generate_user()
417 # Create first attempt and cancel it
418 with postal_verification_session(token) as pv:
419 res = pv.InitiatePostalVerification(
420 postal_verification_pb2.InitiatePostalVerificationReq(
421 address=postal_verification_pb2.PostalAddress(
422 address_line_1="123 Main St",
423 city="Test City",
424 country_code="US",
425 )
426 )
427 )
428 attempt_id_1 = res.postal_verification_attempt_id
430 with postal_verification_session(token) as pv:
431 pv.CancelPostalVerification(
432 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id_1)
433 )
435 # Move created time back to bypass rate limit
436 with session_scope() as session:
437 attempt = session.execute(
438 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id_1)
439 ).scalar_one()
440 attempt.created = now() - POSTAL_VERIFICATION_RATE_LIMIT - timedelta(days=1)
442 # Create second attempt
443 with postal_verification_session(token) as pv:
444 res = pv.InitiatePostalVerification(
445 postal_verification_pb2.InitiatePostalVerificationReq(
446 address=postal_verification_pb2.PostalAddress(
447 address_line_1="456 Other St",
448 city="Other City",
449 country_code="CA",
450 )
451 )
452 )
453 attempt_id_2 = res.postal_verification_attempt_id
455 # List attempts
456 with postal_verification_session(token) as pv:
457 res = pv.ListPostalVerificationAttempts(postal_verification_pb2.ListPostalVerificationAttemptsReq())
458 assert len(res.attempts) == 2
459 # Most recent first
460 assert res.attempts[0].postal_verification_attempt_id == attempt_id_2
461 assert res.attempts[1].postal_verification_attempt_id == attempt_id_1
464def test_postal_verification_address_validation(db):
465 """Test address validation errors."""
466 user, token = generate_user()
468 # Missing required fields
469 with postal_verification_session(token) as pv:
470 # Missing address_line_1
471 with pytest.raises(grpc.RpcError) as e:
472 pv.InitiatePostalVerification(
473 postal_verification_pb2.InitiatePostalVerificationReq(
474 address=postal_verification_pb2.PostalAddress(
475 city="Test City",
476 country_code="US",
477 )
478 )
479 )
480 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
482 # Missing city
483 with pytest.raises(grpc.RpcError) as e:
484 pv.InitiatePostalVerification(
485 postal_verification_pb2.InitiatePostalVerificationReq(
486 address=postal_verification_pb2.PostalAddress(
487 address_line_1="123 Main St",
488 country_code="US",
489 )
490 )
491 )
492 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
494 # Missing country
495 with pytest.raises(grpc.RpcError) as e:
496 pv.InitiatePostalVerification(
497 postal_verification_pb2.InitiatePostalVerificationReq(
498 address=postal_verification_pb2.PostalAddress(
499 address_line_1="123 Main St",
500 city="Test City",
501 )
502 )
503 )
504 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
507def test_postal_verification_postcard_send_failure(db):
508 """Test handling of postcard send failure."""
509 user, token = generate_user()
511 # Initiate and confirm
512 with postal_verification_session(token) as pv:
513 res = pv.InitiatePostalVerification(
514 postal_verification_pb2.InitiatePostalVerificationReq(
515 address=postal_verification_pb2.PostalAddress(
516 address_line_1="123 Main St",
517 city="Test City",
518 country_code="US",
519 )
520 )
521 )
522 attempt_id = res.postal_verification_attempt_id
524 with postal_verification_session(token) as pv:
525 pv.ConfirmPostalAddress(
526 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
527 )
529 # Simulate postcard send failure
530 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
531 mock_send.side_effect = Exception("API error")
532 with pytest.raises(Exception, match="API error"):
533 process_job()
535 # Attempt should still be in_progress (job failed, not the attempt)
536 with postal_verification_session(token) as pv:
537 status = pv.GetPostalVerificationStatus(postal_verification_pb2.GetPostalVerificationStatusReq())
538 assert status.status == postal_verification_pb2.POSTAL_VERIFICATION_STATUS_IN_PROGRESS
541def test_postal_verification_code_case_insensitive(db):
542 """Test that verification codes are case insensitive."""
543 user, token = generate_user()
545 # Initiate and confirm
546 with postal_verification_session(token) as pv:
547 res = pv.InitiatePostalVerification(
548 postal_verification_pb2.InitiatePostalVerificationReq(
549 address=postal_verification_pb2.PostalAddress(
550 address_line_1="123 Main St",
551 city="Test City",
552 country_code="US",
553 )
554 )
555 )
556 attempt_id = res.postal_verification_attempt_id
558 with postal_verification_session(token) as pv:
559 pv.ConfirmPostalAddress(
560 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
561 )
563 # Process background job
564 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
565 mock_send.return_value = 12345
566 while process_job():
567 pass
569 # Get the code
570 with session_scope() as session:
571 attempt = session.execute(
572 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id)
573 ).scalar_one()
574 verification_code = attempt.verification_code
575 assert verification_code
577 # Verify with lowercase code
578 with postal_verification_session(token) as pv:
579 res = pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code.lower()))
580 assert res.success
583def test_postal_verification_attempt_not_found(db):
584 """Test accessing non-existent attempts."""
585 user, token = generate_user()
587 with postal_verification_session(token) as pv:
588 # Try to confirm non-existent attempt
589 with pytest.raises(grpc.RpcError) as e:
590 pv.ConfirmPostalAddress(
591 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=999999)
592 )
593 assert e.value.code() == grpc.StatusCode.NOT_FOUND
595 # Try to cancel non-existent attempt
596 with pytest.raises(grpc.RpcError) as e:
597 pv.CancelPostalVerification(
598 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=999999)
599 )
600 assert e.value.code() == grpc.StatusCode.NOT_FOUND
603def test_postal_verification_other_user_attempt(db):
604 """Test that users cannot access other users' attempts."""
605 user1, token1 = generate_user()
606 user2, token2 = generate_user()
608 # User 1 creates an attempt
609 with postal_verification_session(token1) as pv:
610 res = pv.InitiatePostalVerification(
611 postal_verification_pb2.InitiatePostalVerificationReq(
612 address=postal_verification_pb2.PostalAddress(
613 address_line_1="123 Main St",
614 city="Test City",
615 country_code="US",
616 )
617 )
618 )
619 attempt_id = res.postal_verification_attempt_id
621 # User 2 tries to confirm user 1's attempt
622 with postal_verification_session(token2) as pv:
623 with pytest.raises(grpc.RpcError) as e:
624 pv.ConfirmPostalAddress(
625 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
626 )
627 assert e.value.code() == grpc.StatusCode.NOT_FOUND
629 # User 2 tries to cancel user 1's attempt
630 with postal_verification_session(token2) as pv:
631 with pytest.raises(grpc.RpcError) as e:
632 pv.CancelPostalVerification(
633 postal_verification_pb2.CancelPostalVerificationReq(postal_verification_attempt_id=attempt_id)
634 )
635 assert e.value.code() == grpc.StatusCode.NOT_FOUND
638def test_has_postal_verification_helper(db):
639 """Test the has_postal_verification helper function."""
640 user, token = generate_user()
642 # Initially no verification
643 with session_scope() as session:
644 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one()
645 assert not has_postal_verification(session, db_user)
647 # Complete verification
648 with postal_verification_session(token) as pv:
649 res = pv.InitiatePostalVerification(
650 postal_verification_pb2.InitiatePostalVerificationReq(
651 address=postal_verification_pb2.PostalAddress(
652 address_line_1="123 Main St",
653 city="Test City",
654 country_code="US",
655 )
656 )
657 )
658 attempt_id = res.postal_verification_attempt_id
660 with postal_verification_session(token) as pv:
661 pv.ConfirmPostalAddress(
662 postal_verification_pb2.ConfirmPostalAddressReq(postal_verification_attempt_id=attempt_id)
663 )
665 with patch("couchers.jobs.handlers.send_postcard") as mock_send:
666 mock_send.return_value = 12345
667 while process_job():
668 pass
670 with session_scope() as session:
671 attempt = session.execute(
672 select(PostalVerificationAttempt).where(PostalVerificationAttempt.id == attempt_id)
673 ).scalar_one()
674 verification_code = attempt.verification_code
676 with postal_verification_session(token) as pv:
677 pv.VerifyPostalCode(postal_verification_pb2.VerifyPostalCodeReq(code=verification_code))
679 # Now should have verification
680 with session_scope() as session:
681 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one()
682 assert has_postal_verification(session, db_user)
685def test_generate_postcard_images():
686 """
687 Generates sample postcard front and back images for visual inspection.
689 Output is written to test_artifacts/ (gitignored) and picked up by CI.
690 """
691 code = "ABC123"
692 front = get_postcard_front_image()
693 back = _generate_back_left_side_png(code)
695 assert len(front) > 0
696 assert len(back) > 0
698 output_path = Path(__file__).resolve().parents[2] / "test_artifacts"
699 output_path.mkdir(parents=True, exist_ok=True)
700 (output_path / "postcard_front.png").write_bytes(front)
701 (output_path / "postcard_back.png").write_bytes(back)