Skip to content

Commit 289bb88

Browse files
committed
test: fix mock structures for serper search and filter engine in tasks
1 parent bc6134c commit 289bb88

2 files changed

Lines changed: 125 additions & 131 deletions

File tree

tests/integration/test_tasks.py

Lines changed: 54 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,12 @@
3030

3131
@pytest.fixture(scope="module") # pyright: ignore[reportUntypedFunctionDecorator]
3232
def 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]
3938
async 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]
5451
async 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]
6459
async 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

Comments
 (0)