1+ import io
12import json
2- import multiprocessing
3+ import os
4+ import sys
35import threading
46import time
7+ from contextlib import contextmanager
58from unittest .mock import patch
69
710import numpy as np
811import pandas as pd
912import pytest
1013
14+ from infra .jobspy import _scrape_runner , service as service_module
15+ from infra .jobspy .records import records_from_dataframe
1116from infra .jobspy .service import JobSearchService
1217
1318
@@ -16,11 +21,6 @@ def service():
1621 return JobSearchService ()
1722
1823
19- @pytest .fixture
20- def empty_df ():
21- return pd .DataFrame ()
22-
23-
2424@pytest .fixture
2525def sample_df ():
2626 return pd .DataFrame (
@@ -33,11 +33,155 @@ def sample_df():
3333 )
3434
3535
36+ @contextmanager
37+ def fake_runner (tmp_path , body : str ):
38+ """Substitui o runner real por um script controlado em `tmp_path`.
39+
40+ `_run_scrape` executa `python -m <_RUNNER_MODULE>` com cwd `_PROJECT_ROOT`;
41+ apontando os dois para o script falso exercitamos o protocolo de verdade
42+ (JSON no stdin, JSON no stdout, kill no timeout) sem tocar a rede.
43+ """
44+ (tmp_path / "fake_runner.py" ).write_text (body )
45+ with (
46+ patch .dict (os .environ ),
47+ patch .multiple (
48+ service_module ,
49+ _PROJECT_ROOT = tmp_path ,
50+ _RUNNER_MODULE = "fake_runner" ,
51+ ),
52+ ):
53+ # O pytest-cov instrumenta subprocessos via COV_CORE_*/COVERAGE_PROCESS_*;
54+ # herdadas aqui, elas fazem o runner falso largar arquivos .coverage.*
55+ # órfãos no repo (ele roda com outro cwd, sem achar a config, e o combine
56+ # quebra ao juntar dados com e sem branch coverage).
57+ for key in [k for k in os .environ if k .startswith (("COV_CORE" , "COVERAGE_" ))]:
58+ del os .environ [key ]
59+ yield
60+
61+
62+ # Runner falso que devolve uma vaga fixa, ignorando os kwargs recebidos.
63+ RUNNER_OK = """
64+ import json, sys
65+ json.load(sys.stdin)
66+ json.dump({"records": [{"title": "Python Developer"}]}, sys.stdout)
67+ """
68+
69+ # Runner falso que trava — reproduz o laço infinito de paginação do LinkedIn.
70+ RUNNER_HANGS = """
71+ import time
72+ time.sleep(60)
73+ """
74+
75+
76+ class TestRunScrape :
77+ """Contrato entre `_run_scrape` e o processo runner."""
78+
79+ def test_returns_records_from_runner (self , tmp_path ):
80+ with fake_runner (tmp_path , RUNNER_OK ):
81+ status , payload = service_module ._run_scrape ({"search_term" : "python" }, 30 )
82+
83+ assert status == "ok"
84+ assert payload == [{"title" : "Python Developer" }]
85+
86+ def test_forwards_kwargs_through_stdin (self , tmp_path ):
87+ # O runner devolve os kwargs que recebeu, provando que a serialização
88+ # de ida chegou íntegra do outro lado.
89+ with fake_runner (
90+ tmp_path ,
91+ """
92+ import json, sys
93+ json.dump({"records": [json.load(sys.stdin)]}, sys.stdout)
94+ """ ,
95+ ):
96+ status , payload = service_module ._run_scrape (
97+ {"search_term" : "python" , "site_name" : ["linkedin" ]}, 30
98+ )
99+
100+ assert status == "ok"
101+ assert payload == [{"search_term" : "python" , "site_name" : ["linkedin" ]}]
102+
103+ def test_runner_error_payload_is_propagated (self , tmp_path ):
104+ with fake_runner (
105+ tmp_path ,
106+ """
107+ import json, sys
108+ json.dump({"error": "RuntimeError: scraper explodiu"}, sys.stdout)
109+ """ ,
110+ ):
111+ status , payload = service_module ._run_scrape ({}, 30 )
112+
113+ assert status == "error"
114+ assert payload == "RuntimeError: scraper explodiu"
115+
116+ def test_death_without_output_is_reported_as_error (self , tmp_path ):
117+ # Cenário do OOM killer: o processo some sem escrever nada no stdout.
118+ with fake_runner (
119+ tmp_path ,
120+ """
121+ import os, sys
122+ print("boom", file=sys.stderr)
123+ os._exit(9)
124+ """ ,
125+ ):
126+ status , payload = service_module ._run_scrape ({}, 30 )
127+
128+ assert status == "error"
129+ assert "codigo 9" in payload
130+ assert "boom" in payload
131+
132+ def test_garbage_on_stdout_is_reported_as_error (self , tmp_path ):
133+ with fake_runner (tmp_path , "print('nao sou json')" ):
134+ status , payload = service_module ._run_scrape ({}, 30 )
135+
136+ assert status == "error"
137+ assert "saida invalida" in payload
138+
139+ def test_timeout_reports_timeout (self , tmp_path ):
140+ with fake_runner (tmp_path , RUNNER_HANGS ):
141+ started = time .monotonic ()
142+ status , payload = service_module ._run_scrape ({}, 1 )
143+ elapsed = time .monotonic () - started
144+
145+ assert (status , payload ) == ("timeout" , None )
146+ # Voltou no orçamento, não nos 60s do runner travado.
147+ assert elapsed < 15
148+
149+ def test_timeout_actually_kills_the_runner (self , tmp_path ):
150+ """Regressão do incidente de produção: um scrape travado seguia queimando
151+ CPU depois do timeout (thread inabortável dentro do worker Celery). O
152+ runner falso escreve num arquivo enquanto vive — se o arquivo continuar
153+ crescendo depois do timeout, o processo sobreviveu."""
154+ heartbeat = tmp_path / "heartbeat"
155+ with fake_runner (
156+ tmp_path ,
157+ f"""
158+ import time
159+ with open({ str (heartbeat )!r} , "a", buffering=1) as fh:
160+ while True:
161+ fh.write("tick\\ n")
162+ time.sleep(0.05)
163+ """ ,
164+ ):
165+ assert service_module ._run_scrape ({}, 1 )[0 ] == "timeout"
166+
167+ size_after_timeout = heartbeat .stat ().st_size
168+ time .sleep (0.5 )
169+ assert heartbeat .stat ().st_size == size_after_timeout
170+
171+ def test_hung_runner_leaves_no_thread_behind (self , tmp_path ):
172+ threads_before = threading .active_count ()
173+
174+ with fake_runner (tmp_path , RUNNER_HANGS ):
175+ assert service_module ._run_scrape ({}, 1 )[0 ] == "timeout"
176+
177+ assert threading .active_count () == threads_before
178+
179+
36180@pytest .mark .django_db
37- class TestJobSearchServiceNewParams :
181+ class TestJobSearchServiceParams :
38182 """Os kwargs são verificados na fronteira `_run_scrape` porque `scrape_jobs`
39- passou a ser chamado no subprocesso — um mock patcheado aqui registra a
40- chamada na memória do filho, invisível para o processo de teste."""
183+ roda no processo runner — um mock patcheado aqui registra a chamada na
184+ memória do filho, invisível para o processo de teste."""
41185
42186 @patch ("infra.jobspy.service._run_scrape" )
43187 def test_search_accepts_new_params (self , mock_run , service ):
@@ -89,9 +233,26 @@ def test_search_skips_term_on_error(self, mock_run, service):
89233 mock_run .return_value = ("error" , "RuntimeError: boom" )
90234 assert service .search (terms = ["python" ]) == []
91235
236+ @patch ("infra.jobspy.service._run_scrape" )
237+ def test_search_skips_term_on_timeout (self , mock_run , service ):
238+ mock_run .return_value = ("timeout" , None )
239+ assert service .search (terms = ["python" ]) == []
240+
241+ @patch ("infra.jobspy.service._run_scrape" )
242+ def test_search_continues_to_next_term_after_timeout (self , mock_run , service ):
243+ mock_run .side_effect = [
244+ ("timeout" , None ),
245+ ("ok" , [{"title" : "Python Developer" }]),
246+ ]
247+
248+ result = service .search (terms = ["trava" , "python" ])
249+
250+ assert len (result ) == 1
251+ assert result [0 ]["title" ] == "Python Developer"
252+
92253 @patch ("infra.jobspy.service._run_scrape" )
93254 def test_search_survives_subprocess_start_failure (self , mock_run , service ):
94- # Falhar ao criar o subprocesso (fork sem memória, por exemplo) não pode
255+ # Falhar ao criar o subprocesso (sem memória, por exemplo) não pode
95256 # abortar os demais termos da busca.
96257 mock_run .side_effect = [
97258 OSError ("Cannot allocate memory" ),
@@ -104,111 +265,92 @@ def test_search_survives_subprocess_start_failure(self, mock_run, service):
104265 assert result [0 ]["title" ] == "Python Developer"
105266
106267
107- @pytest .mark .django_db
108- class TestJobSearchServiceTimeout :
109- @patch ("infra.jobspy.service.scrape_jobs" )
110- def test_search_returns_empty_when_scrape_hangs (self , mock_scrape , service ):
111- # Simula scrape_jobs preso (o scraper do LinkedIn entra em laço infinito
112- # de paginação quando o termo não retorna vagas novas). search() deve
113- # abortar no timeout e seguir adiante com lista vazia.
114- def slow_scrape (* args , ** kwargs ):
115- time .sleep (30.0 )
116- return pd .DataFrame ()
117-
118- mock_scrape .side_effect = slow_scrape
119-
120- result = service .search (terms = ["python" ], timeout = 1 )
121-
122- assert result == []
123-
124- @patch ("infra.jobspy.service.scrape_jobs" )
125- def test_search_continues_to_next_term_after_timeout (self , mock_scrape , service , sample_df ):
126- # Primeiro termo trava, segundo retorna normalmente. Resultado deve
127- # conter apenas o segundo.
128- def side_effect (* args , ** kwargs ):
129- if kwargs .get ("search_term" ) == "trava" :
130- time .sleep (30.0 )
131- return pd .DataFrame ()
132- return sample_df
268+ class TestScrapeRunner :
269+ """`_scrape_runner.main()` chamado no processo de teste, com o jobspy
270+ mockado — o que roda em produção é o mesmo código, só que via `python -m`."""
133271
134- mock_scrape .side_effect = side_effect
272+ def _run_main (self , monkeypatch , scrape_kwargs : dict ) -> tuple [int , dict ]:
273+ response = io .StringIO ()
274+ monkeypatch .setattr (sys , "stdin" , io .StringIO (json .dumps (scrape_kwargs )))
275+ monkeypatch .setattr (sys , "stdout" , response )
135276
136- result = service . search ( terms = [ "trava" , "python" ], timeout = 1 )
277+ code = _scrape_runner . main ( )
137278
138- assert len (result ) == 1
139- assert result [0 ]["title" ] == "Python Developer"
279+ return code , json .loads (response .getvalue ())
140280
281+ @patch ("jobspy.scrape_jobs" )
282+ def test_main_emits_records (self , mock_scrape , monkeypatch , sample_df ):
283+ mock_scrape .return_value = sample_df
141284
142- @pytest .mark .django_db
143- class TestJobSearchServiceIsolation :
144- """Regressão do incidente de produção: um scrape travado acumulava threads
145- inabortáveis no worker Celery (~32/dia), saturando a CPU do host."""
285+ code , payload = self ._run_main (monkeypatch , {"search_term" : "python" })
146286
147- @patch ("infra.jobspy.service.scrape_jobs" )
148- def test_hung_scrape_leaves_no_thread_behind (self , mock_scrape , service ):
149- def hang (* args , ** kwargs ):
150- time .sleep (30.0 )
151- return pd .DataFrame ()
287+ assert code == 0
288+ assert payload ["records" ][0 ]["title" ] == "Python Developer"
289+ assert mock_scrape .call_args .kwargs == {"search_term" : "python" }
152290
153- mock_scrape .side_effect = hang
291+ @patch ("jobspy.scrape_jobs" )
292+ def test_main_reports_scrape_failure (self , mock_scrape , monkeypatch ):
293+ mock_scrape .side_effect = RuntimeError ("scraper explodiu" )
154294
155- threads_before = threading .active_count ()
156- assert service .search (terms = ["trava" ], timeout = 1 ) == []
157- assert threading .active_count () == threads_before
295+ code , payload = self ._run_main (monkeypatch , {"search_term" : "python" })
158296
159- @patch ("infra.jobspy.service.scrape_jobs" )
160- def test_hung_scrape_leaves_no_orphan_process (self , mock_scrape , service ):
161- def hang (* args , ** kwargs ):
162- time .sleep (30.0 )
163- return pd .DataFrame ()
297+ assert code == 1
298+ assert payload ["error" ] == "RuntimeError: scraper explodiu"
164299
165- mock_scrape .side_effect = hang
300+ @patch ("jobspy.scrape_jobs" )
301+ def test_main_keeps_stdout_clean (self , mock_scrape , monkeypatch , sample_df , capsys ):
302+ # Uma lib que imprima no stdout não pode corromper a resposta JSON.
303+ def noisy_scrape (** kwargs ):
304+ print ("log solto do scraper" )
305+ return sample_df
166306
167- children_before = len (multiprocessing .active_children ())
168- assert service .search (terms = ["trava" ], timeout = 1 ) == []
169- assert len (multiprocessing .active_children ()) == children_before
307+ mock_scrape .side_effect = noisy_scrape
170308
171- @patch ("infra.jobspy.service.scrape_jobs" )
172- def test_crash_in_subprocess_is_reported_as_error (self , mock_scrape , service ):
173- mock_scrape .side_effect = RuntimeError ("scraper explodiu" )
309+ code , payload = self ._run_main (monkeypatch , {"search_term" : "python" })
174310
175- assert service .search (terms = ["python" ]) == []
311+ assert code == 0
312+ assert payload ["records" ][0 ]["title" ] == "Python Developer"
313+ assert "log solto do scraper" in capsys .readouterr ().err
176314
177- @patch ("infra.jobspy.service.scrape_jobs" )
178- def test_successful_search_leaves_no_orphan_process (self , mock_scrape , service , sample_df ):
179- mock_scrape .return_value = sample_df
315+ def test_main_reports_invalid_stdin (self , monkeypatch ):
316+ response = io .StringIO ()
317+ monkeypatch .setattr (sys , "stdin" , io .StringIO ("nao sou json" ))
318+ monkeypatch .setattr (sys , "stdout" , response )
180319
181- children_before = len (multiprocessing .active_children ())
182- result = service .search (terms = ["python" ])
320+ code = _scrape_runner .main ()
183321
184- assert len ( result ) == 1
185- assert len ( multiprocessing . active_children ()) == children_before
322+ assert code == 1
323+ assert "kwargs invalidos" in json . loads ( response . getvalue ())[ "error" ]
186324
187325
188- @pytest .mark .django_db
189- class TestJobSearchServiceJsonSafe :
190- @patch ("infra.jobspy.service.scrape_jobs" )
191- def test_search_results_are_json_serializable (self , mock_scrape , service ):
326+ class TestRecordsFromDataframe :
327+ def test_records_are_json_serializable (self ):
192328 # Regressão: results são gravados em request.session no admin
193329 # (SearchTermAdmin.test_search). Django usa JSONSerializer por padrão,
194330 # então Timestamp, NaN e numpy scalars vindos de scrape_jobs precisam
195- # estar normalizados antes de retornarem do service.
196- mock_scrape .return_value = pd .DataFrame (
197- {
198- "title" : ["Python Dev" ],
199- "company" : ["Acme" ],
200- "date_posted" : [pd .Timestamp ("2026-04-26" )],
201- "salary" : [np .nan ],
202- "applicants" : [np .int64 (42 )],
203- }
331+ # estar normalizados antes de chegarem ao service.
332+ records = records_from_dataframe (
333+ pd .DataFrame (
334+ {
335+ "title" : ["Python Dev" ],
336+ "company" : ["Acme" ],
337+ "date_posted" : [pd .Timestamp ("2026-04-26" )],
338+ "salary" : [np .nan ],
339+ "applicants" : [np .int64 (42 )],
340+ }
341+ )
204342 )
205343
206- result = service .search (terms = ["python" ])
207-
208344 # Deve ser serializável sem default=str
209- json .dumps (result )
345+ json .dumps (records )
210346
211- record = result [0 ]
347+ record = records [0 ]
212348 assert isinstance (record ["date_posted" ], str )
213349 assert record ["salary" ] is None
214350 assert isinstance (record ["applicants" ], int )
351+
352+ def test_empty_dataframe_returns_empty_list (self ):
353+ assert records_from_dataframe (pd .DataFrame ()) == []
354+
355+ def test_none_returns_empty_list (self ):
356+ assert records_from_dataframe (None ) == []
0 commit comments