@@ -263,6 +263,127 @@ def test_ai_adapter_raises_error_for_unsupported_model(self, mock_api_key, mock_
263263 with pytest .raises (ValueError , match = "Unsupported model type" ):
264264 AIAdapter (mock_api_key , mock_llm_proxy )
265265
266+ @patch ("src.llm.llm_manager.FREE_TIER" , False )
267+ @patch ("src.llm.llm_manager.LLM_MODEL_TYPE" , "openai" )
268+ @patch ("src.llm.llm_manager.EASY_APPLY_MODEL" , "gpt-4" )
269+ @patch ("src.llm.llm_manager.OpenAIModel" )
270+ @patch ("src.llm.llm_manager.pause" )
271+ def test_ai_adapter_no_rate_limiting_when_free_tier_disabled (
272+ self , mock_pause , mock_openai , mock_api_key , mock_llm_proxy
273+ ):
274+ """Test AIAdapter does not apply rate limiting when free tier is disabled"""
275+ mock_model = MagicMock ()
276+ mock_model .invoke .return_value = AIMessage (content = "Test response" )
277+ mock_openai .return_value = mock_model
278+
279+ adapter = AIAdapter (mock_api_key , mock_llm_proxy )
280+
281+ # Make multiple requests
282+ for _ in range (5 ):
283+ adapter .invoke ("Test prompt" )
284+
285+ # Verify pause was never called
286+ mock_pause .assert_not_called ()
287+
288+ @patch ("src.llm.llm_manager.FREE_TIER" , True )
289+ @patch ("src.llm.llm_manager.FREE_TIER_RPM_LIMIT" , 3 )
290+ @patch ("src.llm.llm_manager.LLM_MODEL_TYPE" , "openai" )
291+ @patch ("src.llm.llm_manager.EASY_APPLY_MODEL" , "gpt-4" )
292+ @patch ("src.llm.llm_manager.OpenAIModel" )
293+ @patch ("src.llm.llm_manager.pause" )
294+ def test_ai_adapter_no_pause_under_rpm_limit (
295+ self , mock_pause , mock_openai , mock_api_key , mock_llm_proxy
296+ ):
297+ """Test AIAdapter does not pause when requests are under RPM limit"""
298+ mock_model = MagicMock ()
299+ mock_model .invoke .return_value = AIMessage (content = "Test response" )
300+ mock_openai .return_value = mock_model
301+
302+ adapter = AIAdapter (mock_api_key , mock_llm_proxy )
303+
304+ # Make requests under the limit (3)
305+ for _ in range (2 ):
306+ adapter .invoke ("Test prompt" )
307+
308+ # Verify pause was not called
309+ mock_pause .assert_not_called ()
310+
311+ @patch ("src.llm.llm_manager.FREE_TIER" , True )
312+ @patch ("src.llm.llm_manager.FREE_TIER_RPM_LIMIT" , 3 )
313+ @patch ("src.llm.llm_manager.LLM_MODEL_TYPE" , "openai" )
314+ @patch ("src.llm.llm_manager.EASY_APPLY_MODEL" , "gpt-4" )
315+ @patch ("src.llm.llm_manager.OpenAIModel" )
316+ @patch ("src.llm.llm_manager.pause" )
317+ @patch ("src.llm.llm_manager.datetime" )
318+ def test_ai_adapter_pauses_when_rpm_limit_reached (
319+ self , mock_datetime , mock_pause , mock_openai , mock_api_key , mock_llm_proxy
320+ ):
321+ """Test AIAdapter pauses when RPM limit is reached within 60 seconds"""
322+ from datetime import datetime , timedelta
323+
324+ # Setup mock datetime
325+ base_time = datetime (2024 , 1 , 1 , 12 , 0 , 0 )
326+ mock_datetime .now .side_effect = [
327+ base_time , # First request
328+ base_time + timedelta (seconds = 10 ), # Second request
329+ base_time + timedelta (seconds = 20 ), # Third request
330+ base_time + timedelta (seconds = 30 ), # Fourth request - triggers check
331+ base_time + timedelta (seconds = 30 ), # During pause calculation
332+ ]
333+
334+ mock_model = MagicMock ()
335+ mock_model .invoke .return_value = AIMessage (content = "Test response" )
336+ mock_openai .return_value = mock_model
337+
338+ adapter = AIAdapter (mock_api_key , mock_llm_proxy )
339+
340+ # Make requests that hit the limit (3 requests in queue, 4th triggers pause)
341+ for _ in range (4 ):
342+ adapter .invoke ("Test prompt" )
343+
344+ # Verify pause was called
345+ # Time delta = 30 seconds since first request
346+ # Should pause for 60 - 30 = 30 seconds
347+ assert mock_pause .call_count == 1
348+ call_args = mock_pause .call_args [0 ]
349+ assert call_args [0 ] == 30.0 # pause duration
350+ assert call_args [1 ] == 31.0 # pause duration + 1
351+
352+ @patch ("src.llm.llm_manager.FREE_TIER" , True )
353+ @patch ("src.llm.llm_manager.FREE_TIER_RPM_LIMIT" , 2 )
354+ @patch ("src.llm.llm_manager.LLM_MODEL_TYPE" , "openai" )
355+ @patch ("src.llm.llm_manager.EASY_APPLY_MODEL" , "gpt-4" )
356+ @patch ("src.llm.llm_manager.OpenAIModel" )
357+ @patch ("src.llm.llm_manager.pause" )
358+ @patch ("src.llm.llm_manager.datetime" )
359+ def test_ai_adapter_no_pause_after_60_seconds (
360+ self , mock_datetime , mock_pause , mock_openai , mock_api_key , mock_llm_proxy
361+ ):
362+ """Test AIAdapter does not pause if oldest request is older than 60 seconds"""
363+ from datetime import datetime , timedelta
364+
365+ # Setup mock datetime - requests spaced more than 60 seconds apart
366+ base_time = datetime (2024 , 1 , 1 , 12 , 0 , 0 )
367+ mock_datetime .now .side_effect = [
368+ base_time , # First request
369+ base_time + timedelta (seconds = 30 ), # Second request
370+ base_time + timedelta (seconds = 65 ), # Third request - 65 seconds after first
371+ base_time + timedelta (seconds = 65 ), # During check
372+ ]
373+
374+ mock_model = MagicMock ()
375+ mock_model .invoke .return_value = AIMessage (content = "Test response" )
376+ mock_openai .return_value = mock_model
377+
378+ adapter = AIAdapter (mock_api_key , mock_llm_proxy )
379+
380+ # Make 3 requests
381+ for _ in range (3 ):
382+ adapter .invoke ("Test prompt" )
383+
384+ # Verify pause was not called since 60+ seconds passed
385+ mock_pause .assert_not_called ()
386+
266387
267388class TestLLMLogger :
268389 """Tests for LLMLogger class"""
@@ -460,11 +581,17 @@ def test_gpt_answerer_set_job(
460581 ):
461582 """Test GPTAnswerer set_job method"""
462583 mock_transform .return_value = "Transformed job data"
584+
585+ # Mock the summarize chain
586+ mock_chain = MagicMock ()
587+ mock_chain .invoke .return_value = "Brief job description"
588+
463589 answerer = GPTAnswerer (mock_api_key , mock_llm_proxy )
590+ answerer .chains ["summarize_job_description" ] = mock_chain
464591 answerer .set_job (mock_job )
465592
466593 assert answerer .job == mock_job
467- assert answerer .job_readable == "Transformed job data "
594+ assert answerer .job_readable == "Brief job description "
468595 mock_transform .assert_called_once_with (mock_job )
469596
470597 @patch ("src.llm.llm_manager.transform_search_config_data" )
@@ -606,17 +733,22 @@ def test_gpt_answerer_job_is_interesting_true(
606733 mock_job ,
607734 ):
608735 """Test GPTAnswerer job_is_interesting returns True"""
609- # Setup mock chain
736+ # Setup mock chain for job_is_interesting
610737 mock_chain = MagicMock ()
611738 mock_chain .invoke .return_value = "Score: 85\n Reasoning: Great match for skills"
612739
740+ # Setup mock chain for summarize_job_description
741+ mock_summarize_chain = MagicMock ()
742+ mock_summarize_chain .invoke .return_value = "Brief job description"
743+
613744 answerer = GPTAnswerer (mock_api_key , mock_llm_proxy )
614- answerer .chains = {"job_is_interesting" : mock_chain }
745+ answerer .chains ["job_is_interesting" ] = mock_chain
746+ answerer .chains ["summarize_job_description" ] = mock_summarize_chain
615747 answerer .set_resume (mock_resume_structured , mock_resume_readable )
616748 answerer .set_job (mock_job )
617749 answerer .search_parameters = "Remote: True"
618750
619- is_interesting , score , reasoning = answerer .job_is_interesting ()
751+ is_interesting , score , reasoning = answerer .job_is_interesting (mock_job )
620752
621753 assert is_interesting is True
622754 assert score == "85"
@@ -636,17 +768,22 @@ def test_gpt_answerer_job_is_interesting_false(
636768 mock_job ,
637769 ):
638770 """Test GPTAnswerer job_is_interesting returns False"""
639- # Setup mock chain
771+ # Setup mock chain for job_is_interesting
640772 mock_chain = MagicMock ()
641773 mock_chain .invoke .return_value = "Score: 50\n Reasoning: Not a good fit"
642774
775+ # Setup mock chain for summarize_job_description
776+ mock_summarize_chain = MagicMock ()
777+ mock_summarize_chain .invoke .return_value = "Brief job description"
778+
643779 answerer = GPTAnswerer (mock_api_key , mock_llm_proxy )
644- answerer .chains = {"job_is_interesting" : mock_chain }
780+ answerer .chains ["job_is_interesting" ] = mock_chain
781+ answerer .chains ["summarize_job_description" ] = mock_summarize_chain
645782 answerer .set_resume (mock_resume_structured , mock_resume_readable )
646783 answerer .set_job (mock_job )
647784 answerer .search_parameters = "Remote: True"
648785
649- is_interesting , score , reasoning = answerer .job_is_interesting ()
786+ is_interesting , score , reasoning = answerer .job_is_interesting (mock_job )
650787
651788 assert is_interesting is False
652789 assert score == "50"
@@ -665,19 +802,23 @@ def test_gpt_answerer_write_cover_letter(
665802 mock_job ,
666803 ):
667804 """Test GPTAnswerer write_cover_letter method"""
668- # Setup mock chain
805+ # Setup mock chain for cover letter
669806 mock_chain = MagicMock ()
670807 mock_chain .invoke .return_value = "Dear Hiring Manager,\n \n I am writing..."
671808
809+ # Setup mock chain for summarize_job_description
810+ mock_summarize_chain = MagicMock ()
811+ mock_summarize_chain .invoke .return_value = "Brief job description"
812+
672813 answerer = GPTAnswerer (mock_api_key , mock_llm_proxy )
673- answerer ._create_chain = MagicMock (return_value = mock_chain )
814+ answerer .chains ["summarize_job_description" ] = mock_summarize_chain
815+ answerer .chains ["prompt_cover_letter" ] = mock_chain
674816 answerer .set_resume (mock_resume_structured , mock_resume_readable )
675817 answerer .set_job (mock_job )
676818
677819 result = answerer .write_cover_letter ()
678820
679821 assert "Dear Hiring Manager" in result
680- answerer ._create_chain .assert_called_once ()
681822
682823 @patch ("src.llm.llm_manager.AIAdapter" )
683824 @patch ("src.llm.llm_manager.LoggerChatModel" )
@@ -720,11 +861,20 @@ def test_gpt_answerer_generate_html_resume(
720861 mock_job ,
721862 ):
722863 """Test GPTAnswerer generate_html_resume method"""
864+ # Setup mock chain for summarize_job_description
865+ mock_summarize_chain = MagicMock ()
866+ mock_summarize_chain .invoke .return_value = "Brief job description"
867+
723868 # Setup mock methods
724869 answerer = GPTAnswerer (mock_api_key , mock_llm_proxy )
870+ answerer .chains ["summarize_job_description" ] = mock_summarize_chain
725871 answerer .set_resume (mock_resume_structured , mock_resume_readable )
726872 answerer .set_job (mock_job )
727873
874+ # Mock load_resume_template to return empty string so generate methods are called
875+ answerer .load_resume_template = MagicMock (return_value = "" )
876+ answerer .save_resume_template = MagicMock ()
877+
728878 answerer .generate_header = MagicMock (return_value = "<header>Header</header>" )
729879 answerer .generate_education_section = MagicMock (return_value = "<section>Education</section>" )
730880 answerer .generate_work_experience_section = MagicMock (
0 commit comments