diff --git a/src/job_research/cli.py b/src/job_research/cli.py index 10c627d..6246946 100644 --- a/src/job_research/cli.py +++ b/src/job_research/cli.py @@ -94,6 +94,10 @@ def fetch_apec( profile = CandidateProfileOutput.model_validate(load_yaml(profile_path)) derived_queries = derive_apec_queries(profile) + if not derived_queries: + typer.echo("No usable Apec queries derived from candidate profile", err=True) + raise typer.Exit(code=1) + current = _utc_now().astimezone(timezone.utc).replace(microsecond=0) run_id = current.strftime("%Y-%m-%dT%H-%M-%SZ") fetched_at = current.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -101,20 +105,21 @@ def fetch_apec( paths["snapshots"].mkdir(parents=True, exist_ok=True) adapter = ApecAdapter(max_listings=50) - search_results = adapter.search(derived_queries) + search_results = adapter.search(derived_queries)[:50] normalized_listings = [] listing_errors: list[ListingError] = [] fetched_count = 0 for index, result in enumerate(search_results, start=1): + fetched_count += 1 + try: html = adapter.fetch_listing_html(result.url) except Exception as exc: # pragma: no cover - defensive boundary listing_errors.append(ListingError(url=result.url, stage="fetch_html", message=str(exc))) continue - fetched_count += 1 snapshot_path = paths["snapshots"] / f"{index:04d}.html" snapshot_path.write_text(html, encoding="utf-8") @@ -131,6 +136,10 @@ def fetch_apec( normalized_listings.append(listing) + if fetched_count > 0 and not normalized_listings: + typer.echo("No listings could be fetched or normalized from Apec", err=True) + raise typer.Exit(code=1) + deduplicated_listings = dedupe_apec_listings(normalized_listings) run_meta = ApecRunMeta( derived_queries=derived_queries, diff --git a/tests/test_apec_cli.py b/tests/test_apec_cli.py index 9060fa1..d4b6c91 100644 --- a/tests/test_apec_cli.py +++ b/tests/test_apec_cli.py @@ -8,6 +8,10 @@ from job_research.apec.adapter import ApecSearchResult from job_research.cli import app +def _fixed_now() -> datetime: + return datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc) + + def test_fetch_apec_reads_profile_and_writes_run_artifacts(tmp_path, monkeypatch) -> None: data_root = tmp_path / "data" data_root.mkdir() @@ -55,7 +59,7 @@ def test_fetch_apec_reads_profile_and_writes_run_artifacts(tmp_path, monkeypatch monkeypatch.setattr("job_research.cli.ApecAdapter", FakeApecAdapter) monkeypatch.setattr( "job_research.cli._utc_now", - lambda: datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc), + _fixed_now, ) result = CliRunner().invoke(app, ["fetch-apec", "--data-root", str(data_root)]) @@ -107,3 +111,158 @@ def test_fetch_apec_reads_profile_and_writes_run_artifacts(tmp_path, monkeypatch "failed_count": 0, "listing_errors": [], } + + +def test_fetch_apec_fails_when_no_queries_are_derived(tmp_path, monkeypatch) -> None: + data_root = tmp_path / "data" + data_root.mkdir() + (data_root / "candidate-profile.yaml").write_text("target_roles: []\n", encoding="utf-8") + + class SpyApecAdapter: + instances: list["SpyApecAdapter"] = [] + + def __init__(self, max_listings: int = 50) -> None: + self.max_listings = max_listings + SpyApecAdapter.instances.append(self) + + def search(self, queries: list[str]) -> list[ApecSearchResult]: + raise AssertionError("search should not be called when there are no queries") + + def fetch_listing_html(self, url: str) -> str: + raise AssertionError("fetch should not be called when there are no queries") + + monkeypatch.setattr("job_research.cli.ApecAdapter", SpyApecAdapter) + monkeypatch.setattr("job_research.cli._utc_now", _fixed_now) + + result = CliRunner().invoke(app, ["fetch-apec", "--data-root", str(data_root)]) + + assert result.exit_code == 1 + assert "No usable Apec queries derived from candidate profile" in result.stderr + assert SpyApecAdapter.instances == [] + assert not (data_root / "apec").exists() + + +def test_fetch_apec_processes_only_the_first_fifty_search_results(tmp_path, monkeypatch) -> None: + data_root = tmp_path / "data" + data_root.mkdir() + (data_root / "candidate-profile.yaml").write_text( + dedent( + """ + target_roles: + - Role From YAML + """ + ).strip(), + encoding="utf-8", + ) + + html = dedent( + """ + +
+