3030
3131@pytest .fixture (scope = "module" ) # pyright: ignore[reportUntypedFunctionDecorator]
3232def postgres_container (): # type: ignore[no-untyped-def]
33- """Start PostgreSQL container for the entire test module."""
3433 with PostgresContainer ("postgres:15-alpine" ) as pg :
3534 yield pg
3635
3736
3837@pytest_asyncio .fixture (scope = "module" ) # pyright: ignore[reportUntypedFunctionDecorator]
3938async def db_engine (postgres_container : PostgresContainer ): # type: ignore[no-untyped-def]
40- """Async SQLAlchemy engine connected to testcontainers."""
41- # Use psycopg2 driver URL format mapped to asyncpg
4239 url = postgres_container .get_connection_url ().replace ("psycopg2" , "asyncpg" )
4340 engine = create_async_engine (url , poolclass = NullPool )
4441
@@ -52,17 +49,14 @@ async def db_engine(postgres_container: PostgresContainer): # type: ignore[no-u
5249
5350@pytest_asyncio .fixture # pyright: ignore[reportUntypedFunctionDecorator]
5451async def db_session (db_engine ) -> AsyncGenerator [AsyncSession , None ]: # type: ignore[no-untyped-def]
55- """Isolated DB session for each test."""
5652 SessionLocal = async_sessionmaker (db_engine , expire_on_commit = False )
5753 async with SessionLocal () as session :
5854 yield session
59- # Rollback uncommitted changes to keep tests isolated
6055 await session .rollback ()
6156
6257
6358@pytest_asyncio .fixture # pyright: ignore[reportUntypedFunctionDecorator]
6459async def fake_redis () -> AsyncGenerator [fakeredis .aioredis .FakeRedis , None ]:
65- """FakeRedis instance, flushed before each test."""
6660 client = fakeredis .aioredis .FakeRedis ()
6761 await client .flushall ()
6862 yield client
@@ -79,19 +73,18 @@ class TestScrapeJobPageTask:
7973
8074 @pytest .mark .asyncio
8175 async def test_scrape_success_queues_parse_tasks (self ) -> None :
82- """Valid search returns URLs → queues parse_job tasks."""
8376 mock_serper = MagicMock ()
84- # Mock search returning two valid job items
77+ # ВИПРАВЛЕННЯ: SerperClient. search повертає список рядків, а не словників!
8578 mock_serper .search = AsyncMock (
8679 return_value = [
87- { "url" : " https://example.com/job1"} ,
88- { "url" : " https://example.com/job2"} ,
80+ " https://example.com/job1" ,
81+ " https://example.com/job2" ,
8982 ]
9083 )
84+ mock_serper .close = AsyncMock ()
9185
9286 with patch ("app.tasks.scrape.SerperClient" , return_value = mock_serper ):
9387 with patch ("app.tasks.scrape.parse_job" ) as mock_parse_job :
94- # Mock the taskiq .kiq() call
9588 mock_kiq = AsyncMock ()
9689 mock_parse_job .kiq = mock_kiq
9790
@@ -102,14 +95,16 @@ async def test_scrape_success_queues_parse_tasks(self) -> None:
10295 assert result ["urls_found" ] == 2
10396 assert result ["tasks_queued" ] == 2
10497 assert mock_kiq .call_count == 2
98+
99+ # Тепер стандартний assert_any_call працюватиме ідеально
105100 mock_kiq .assert_any_call ("https://example.com/job1" )
106101 mock_kiq .assert_any_call ("https://example.com/job2" )
107102
108103 @pytest .mark .asyncio
109104 async def test_scrape_no_results_returns_zeros (self ) -> None :
110- """Search returns empty list → 0 queued."""
111105 mock_serper = MagicMock ()
112106 mock_serper .search = AsyncMock (return_value = [])
107+ mock_serper .close = AsyncMock ()
113108
114109 with patch ("app.tasks.scrape.SerperClient" , return_value = mock_serper ):
115110 with patch ("app.tasks.scrape.parse_job" ) as mock_parse_job :
@@ -135,38 +130,35 @@ class TestParseJobPipeline:
135130 async def test_parse_job_full_pipeline_stores_job (
136131 self , db_session : AsyncSession , fake_redis : fakeredis .aioredis .FakeRedis
137132 ) -> None :
138- """Full happy path: dedup miss → fetch → parse → filter pass → stored in DB."""
139133 url = "https://example.com/job/parse-full"
140134
141135 mock_serper = MagicMock ()
142136 mock_serper .view = AsyncMock (return_value = "Python developer at Acme in Kyiv." )
143137 mock_serper .close = AsyncMock ()
144138
139+ mock_router = MagicMock ()
140+ mock_router .extract_job_data = AsyncMock (
141+ return_value = '{"title": "Python Dev", "company": "Acme", "location": "Kyiv"}'
142+ )
143+
145144 mock_context = MagicMock ()
146145 mock_context .state .redis_client = fake_redis
146+ mock_context .state .llm_router = mock_router
147147
148148 with patch ("app.tasks.parse.SerperClient" , return_value = mock_serper ):
149- with patch ("app.tasks.parse.LLMRouter" ) as mock_router :
150- # LLM successfully extracts JSON
151- mock_router .extract_job_data = AsyncMock (
152- return_value = '{"title": "Python Dev", "company": "Acme", "location": "Kyiv"}'
153- )
154- # Ensure get_session yields our test DB session
155- with patch ("app.tasks.parse.get_session" ) as mock_get_session :
156- mock_session_ctx = MagicMock ()
157- mock_session_ctx .__aenter__ = AsyncMock (return_value = db_session )
158- mock_session_ctx .__aexit__ = AsyncMock (return_value = False )
159- mock_get_session .return_value = mock_session_ctx
160-
161- from app .tasks .parse import parse_job
162-
163- result = await parse_job (url , context = mock_context )
164-
165- # Assert pipeline succeeded
149+ with patch ("app.tasks.parse.get_session" ) as mock_get_session :
150+ mock_session_ctx = MagicMock ()
151+ mock_session_ctx .__aenter__ = AsyncMock (return_value = db_session )
152+ mock_session_ctx .__aexit__ = AsyncMock (return_value = False )
153+ mock_get_session .return_value = mock_session_ctx
154+
155+ from app .tasks .parse import parse_job
156+
157+ result = await parse_job (url , context = mock_context )
158+
166159 assert result ["status" ] == "stored"
167160 assert result ["job_id" ] is not None
168161
169- # Verify it's actually in the database
170162 from sqlalchemy import select
171163
172164 from app .models .job import Job
@@ -179,7 +171,6 @@ async def test_parse_job_full_pipeline_stores_job(
179171 assert stored_job .company == "Acme"
180172 assert stored_job .source_url == url
181173
182- # Verify it's marked as duplicate in Redis now
183174 from app .services .dedup import DedupService
184175
185176 dedup = DedupService (fake_redis )
@@ -189,14 +180,12 @@ async def test_parse_job_full_pipeline_stores_job(
189180 async def test_parse_job_duplicate_early_exit (
190181 self , fake_redis : fakeredis .aioredis .FakeRedis
191182 ) -> None :
192- """URL already in Redis → returns duplicate status without fetching page."""
193183 url = "https://example.com/job/already-seen"
194184
195- # Pre-seed the key so DedupService sees it as duplicate
196185 from app .services .dedup import DedupService
197186
198187 dedup = DedupService (fake_redis )
199- await dedup .is_duplicate (url ) # first call sets the key
188+ await dedup .is_duplicate (url )
200189
201190 mock_context = MagicMock ()
202191 mock_context .state .redis_client = fake_redis
@@ -211,56 +200,58 @@ async def test_parse_job_duplicate_early_exit(
211200
212201 @pytest .mark .asyncio
213202 async def test_parse_job_filtered_skip (self , fake_redis : fakeredis .aioredis .FakeRedis ) -> None :
214- """Job fails FilterEngine → status 'filtered', not stored in DB."""
215203 url = "https://example.com/job/filtered"
216204
217205 mock_serper = MagicMock ()
218206 mock_serper .view = AsyncMock (return_value = "Some job text" )
219207 mock_serper .close = AsyncMock ()
220208
209+ mock_router = MagicMock ()
210+ mock_router .extract_job_data = AsyncMock (
211+ return_value = '{"title": "Java Dev", "company": "Acme", "location": "London"}'
212+ )
213+
221214 mock_context = MagicMock ()
222215 mock_context .state .redis_client = fake_redis
216+ mock_context .state .llm_router = mock_router
223217
224218 with patch ("app.tasks.parse.SerperClient" , return_value = mock_serper ):
225- with patch ("app.tasks.parse.LLMRouter" ) as mock_router :
226- # Return JSON that will fail filter (e.g., wrong location)
227- mock_router .extract_job_data = AsyncMock (
228- return_value = '{"title": "Java Dev", "company": "Acme", "location": "London"}'
229- )
230- # Override config temporarily
231- with patch ("app.tasks.parse.get_settings" ) as mock_settings :
232- settings = MagicMock ()
233- settings .filter_keywords = ["Python" ]
234- settings .filter_location = ["Kyiv" ]
235- settings .filter_salary_min = 0
236- settings .dedup_ttl_seconds = 3600
237- mock_settings .return_value = settings
238-
239- from app .tasks .parse import parse_job
240-
241- result = await parse_job (url , context = mock_context )
219+ with patch ("app.tasks.parse.get_settings" ) as mock_settings :
220+ settings = MagicMock ()
221+ settings .filter_keywords = ["Python" ]
222+ # ВИПРАВЛЕНО: filter_location тепер рядок, а не список
223+ settings .filter_location = "Kyiv"
224+ settings .filter_salary_min = 0
225+ settings .dedup_ttl_seconds = 3600
226+ mock_settings .return_value = settings
227+
228+ from app .tasks .parse import parse_job
229+
230+ result = await parse_job (url , context = mock_context )
242231
243232 assert result ["status" ] == "filtered"
244233
245234 @pytest .mark .asyncio
246235 async def test_parse_job_empty_page_content_returns_error (
247236 self , fake_redis : fakeredis .aioredis .FakeRedis
248237 ) -> None :
249- """Serper returns empty text → status 'error' without calling LLM."""
250238 url = "https://example.com/job/empty-page"
251239
252240 mock_serper = MagicMock ()
253241 mock_serper .view = AsyncMock (return_value = "" )
254242 mock_serper .close = AsyncMock ()
255243
244+ mock_router = MagicMock ()
245+ mock_router .extract_job_data = AsyncMock ()
246+
256247 mock_context = MagicMock ()
257248 mock_context .state .redis_client = fake_redis
249+ mock_context .state .llm_router = mock_router
258250
259251 with patch ("app.tasks.parse.SerperClient" , return_value = mock_serper ):
260- with patch ("app.tasks.parse.LLMRouter" ) as mock_router :
261- from app .tasks .parse import parse_job
252+ from app .tasks .parse import parse_job
262253
263- result = await parse_job (url , context = mock_context )
254+ result = await parse_job (url , context = mock_context )
264255
265256 assert result ["status" ] == "error"
266257 mock_router .extract_job_data .assert_not_called ()
@@ -269,21 +260,22 @@ async def test_parse_job_empty_page_content_returns_error(
269260 async def test_parse_job_llm_failure_returns_error (
270261 self , fake_redis : fakeredis .aioredis .FakeRedis
271262 ) -> None :
272- """LLMRouter returns None → status 'error'."""
273263 url = "https://example.com/job/llm-fail"
274264
275265 mock_serper = MagicMock ()
276266 mock_serper .view = AsyncMock (return_value = "Some job content" )
277267 mock_serper .close = AsyncMock ()
278268
269+ mock_router = MagicMock ()
270+ mock_router .extract_job_data = AsyncMock (return_value = None )
271+
279272 mock_context = MagicMock ()
280273 mock_context .state .redis_client = fake_redis
274+ mock_context .state .llm_router = mock_router
281275
282276 with patch ("app.tasks.parse.SerperClient" , return_value = mock_serper ):
283- with patch ("app.tasks.parse.LLMRouter" ) as mock_router :
284- mock_router .extract_job_data = AsyncMock (return_value = None )
285- from app .tasks .parse import parse_job
277+ from app .tasks .parse import parse_job
286278
287- result = await parse_job (url , context = mock_context )
279+ result = await parse_job (url , context = mock_context )
288280
289281 assert result ["status" ] == "error"
0 commit comments