@@ -319,16 +319,21 @@ def setUp(self):
319319 # fake constants
320320 self .fake_application = {
321321 "id" : "123" ,
322+ "status" : "active" ,
322323 "role_name" : "Fake Job" ,
323324 "candidate" : {
324325 "id" : "444" ,
325326 "first_name" : "John" ,
326327 "last_name" : "Doe" ,
328+ "email_addresses" : [
329+ {"value" : "candidate@example.com" , "type" : "personal" }
330+ ],
327331 },
328332 "hiring_lead" : {
329333 "id" : "777" ,
330334 "name" : "Hiring Lead" ,
331335 "emails" : ["hiring_lead@example.com" ],
336+ "primary_email" : "hiring_lead@example.com" ,
332337 },
333338 "current_stage" : {"name" : "Fake Stage" },
334339 "jobs" : [{"id" : 456 , "name" : "Fake Job" }],
@@ -424,6 +429,18 @@ def setUp(self):
424429 # set debug to true so we can test and ensure _send_mail is not called
425430 flask .current_app .debug = True
426431
432+ def rendered_call (self , template_suffix ):
433+ """Return the single render_template call for a given template."""
434+ calls = [
435+ call
436+ for call in self .mock_render_template .call_args_list
437+ if call .args [0 ].endswith (template_suffix )
438+ ]
439+ self .assertEqual (
440+ len (calls ), 1 , f"expected one render of { template_suffix } "
441+ )
442+ return calls [0 ]
443+
427444 def test_candidate_withdrawal_process (self ):
428445 call_order = []
429446 mock_harvest = MagicMock (spec = HarvestV3 )
@@ -475,14 +492,11 @@ def delete_interview(*args, **kwargs):
475492
476493 # ensure datetime conversion worked
477494 expected_datetime = "February 29, 2024 at 03:00PM"
478- _ , kwargs = self .mock_render_template . call_args_list [ 0 ]
495+ kwargs = self .rendered_call ( "interview-canceled-email.html" ). kwargs
479496 self .assertIn ("interview_date" , kwargs )
480497 self .assertEqual (kwargs ["interview_date" ], expected_datetime )
481- self .assertEqual (self .mock_render_template .call_count , 4 )
482- feedback_template = self .mock_render_template .call_args_list [1 ].args [0 ]
483- self .assertTrue (
484- feedback_template .endswith ("feedback-not-needed-email.html" )
485- )
498+ self .assertEqual (self .mock_render_template .call_count , 5 )
499+ self .rendered_call ("feedback-not-needed-email.html" )
486500
487501 # ensure that harvest rejection fn is called with correct arguments
488502 mock_harvest .reject_application .assert_called_once_with (
@@ -528,6 +542,106 @@ def test_missing_withdrawal_reason_returns_bad_request(self):
528542
529543 mock_harvest .reject_application .assert_not_called ()
530544
545+ def test_candidate_email_uses_hiring_lead_as_sender (self ):
546+ flask .current_app .debug = False
547+ mock_harvest = MagicMock (spec = HarvestV3 )
548+ mock_harvest .reject_application .return_value = MagicMock (
549+ status_code = 204
550+ )
551+ self .mock_render_template .side_effect = lambda template , ** kwargs : (
552+ template
553+ )
554+
555+ application_withdrawal (mock_harvest , "fake_token" )
556+
557+ candidate_email_call = self .mock_send_mail .call_args_list [0 ]
558+ self .assertEqual (
559+ candidate_email_call .args [0 ], ["candidate@example.com" ]
560+ )
561+ self .assertEqual (
562+ candidate_email_call .args [1 ],
563+ "Withdrawal of application for Fake Job, Canonical" ,
564+ )
565+ self .assertEqual (
566+ candidate_email_call .kwargs ["from_email" ],
567+ "hiring_lead@example.com" ,
568+ )
569+
570+ def test_candidate_email_failure_is_reported_to_hiring_lead (self ):
571+ flask .current_app .debug = False
572+ mock_harvest = MagicMock (spec = HarvestV3 )
573+ mock_harvest .reject_application .return_value = MagicMock (
574+ status_code = 204
575+ )
576+ self .mock_send_mail .side_effect = [RuntimeError ("SMTP failed" ), None ]
577+
578+ application_withdrawal (mock_harvest , "fake_token" )
579+
580+ notification_call = self .rendered_call (
581+ "_withdrawal_notification-email.html"
582+ )
583+ self .assertTrue (notification_call .kwargs ["candidate_email_failed" ])
584+ self .assertEqual (self .mock_send_mail .call_count , 2 )
585+
586+ def test_candidate_email_precedes_interview_cleanup (self ):
587+ flask .current_app .debug = False
588+ mock_harvest = MagicMock (spec = HarvestV3 )
589+ mock_harvest .reject_application .return_value = MagicMock (
590+ status_code = 204
591+ )
592+ call_order = []
593+
594+ def record_email (_to_email , subject , * args , ** kwargs ):
595+ if subject .startswith ("Withdrawal of application" ):
596+ call_order .append ("candidate" )
597+ else :
598+ call_order .append ("hiring_lead" )
599+
600+ def record_interview_cleanup (* args ):
601+ call_order .append ("interviews" )
602+ return []
603+
604+ self .mock_send_mail .side_effect = record_email
605+ with patch (
606+ "webapp.application.try_reject_interviews" ,
607+ side_effect = record_interview_cleanup ,
608+ ):
609+ application_withdrawal (mock_harvest , "fake_token" )
610+
611+ self .assertEqual (
612+ call_order ,
613+ ["candidate" , "interviews" , "hiring_lead" ],
614+ )
615+
616+ @patch ("webapp.application.logger" )
617+ def test_hiring_lead_email_failure_does_not_mask_withdrawal (
618+ self , mock_logger
619+ ):
620+ flask .current_app .debug = False
621+ mock_harvest = MagicMock (spec = HarvestV3 )
622+ mock_harvest .reject_application .return_value = MagicMock (
623+ status_code = 204
624+ )
625+ self .mock_send_mail .side_effect = [None , RuntimeError ("SMTP failed" )]
626+
627+ application_withdrawal (mock_harvest , "fake_token" )
628+
629+ mock_logger .exception .assert_called_once_with (
630+ "failed to send withdrawal notification to hiring lead for "
631+ "application_id=%s" ,
632+ self .fake_application ["id" ],
633+ )
634+
635+ def test_already_rejected_application_has_no_side_effects (self ):
636+ self .fake_application ["status" ] = "rejected"
637+ mock_harvest = MagicMock (spec = HarvestV3 )
638+
639+ application_withdrawal (mock_harvest , "fake_token" )
640+
641+ mock_harvest .reject_application .assert_not_called ()
642+ self .mock_send_mail .assert_not_called ()
643+ self .mock_cal .return_value .delete_interview_event .assert_not_called ()
644+
531645 def tearDown (self ):
532646 # stop all of the patches from setUp
533647 patch .stopall ()
@@ -828,6 +942,29 @@ def test_send_mail_without_authentication(self, mock_smtp):
828942 smtp_instance .send_message .assert_called_once ()
829943 smtp_instance .quit .assert_called_once ()
830944
945+ @patch ("webapp.application.SMTP" )
946+ def test_send_mail_supports_custom_sender (self , mock_smtp ):
947+ smtp_instance = mock_smtp .return_value
948+ with patch .dict (
949+ os .environ ,
950+ {
951+ "SMTP_SERVER" : "smtp.example.com" ,
952+ "SMTP_USER" : "" ,
953+ "SMTP_PASS" : "" ,
954+ "SMTP_SENDER_ADDRESS" : "fallback@example.com" ,
955+ },
956+ clear = True ,
957+ ):
958+ _send_mail (
959+ to_email = ["recipient@example.com" ],
960+ subject = "Subject" ,
961+ message = "<p>HTML body</p>" ,
962+ from_email = "lead@example.com" ,
963+ )
964+
965+ message = smtp_instance .send_message .call_args .args [0 ]
966+ self .assertEqual (message ["From" ], "lead@example.com" )
967+
831968
832969class TestConfirmationToken (unittest .TestCase ):
833970 @patch ("webapp.application._get_cipher" )
0 commit comments