diff --git a/src/job_research/apec/adapter.py b/src/job_research/apec/adapter.py index cae593a..2dbda7c 100644 --- a/src/job_research/apec/adapter.py +++ b/src/job_research/apec/adapter.py @@ -300,4 +300,7 @@ class ApecAdapter: polling=1000, timeout=15_000, ) + final_url = page.url + if not _is_public_apec_detail_url(final_url): + raise ValueError(f"ApecAdapter landed on an unexpected URL after redirects: {final_url}") return page.content() diff --git a/src/job_research/apec/normalize.py b/src/job_research/apec/normalize.py index 5670ec4..78ac275 100644 --- a/src/job_research/apec/normalize.py +++ b/src/job_research/apec/normalize.py @@ -235,6 +235,9 @@ def normalize_apec_listing( description_text = _detail_block_text(soup, "Descriptif du poste") if description_text is None: warnings.append(_warning("description_text", "Description missing from Apec listing")) + elif not _has_useful_text(description_text): + warnings.append(_warning("description_text", "Description is empty or placeholder text")) + description_text = None if source_job_id is not None: normalized_source_job_id = source_job_id @@ -283,7 +286,7 @@ def normalize_apec_listing( company=company_text, location=location if _has_useful_text(location) else None, contract_type=contract_type if _has_useful_text(contract_type) else None, - description_text=description_text or None, + description_text=description_text, published_at=published_at_value, refreshed_at=refreshed_at_value, fetched_at=fetched_at, diff --git a/src/job_research/cli.py b/src/job_research/cli.py index 41a61f7..bdeaddf 100644 --- a/src/job_research/cli.py +++ b/src/job_research/cli.py @@ -115,7 +115,11 @@ def build_profile( cv_signals = extract_cv_signals(cv_text) candidate_profile = build_candidate_profile_output(cv_signals, authored_profile) - save_candidate_profile_yaml(out, candidate_profile) + try: + save_candidate_profile_yaml(out, candidate_profile) + except OSError as exc: + typer.echo(f"Unable to write candidate profile to {out}: {exc}", err=True) + raise typer.Exit(code=1) typer.echo(f"candidate profile written to {out}") warning_count = len(candidate_profile.warnings) diff --git a/tests/apec/test_adapter.py b/tests/apec/test_adapter.py index 567e510..870278e 100644 --- a/tests/apec/test_adapter.py +++ b/tests/apec/test_adapter.py @@ -512,6 +512,27 @@ def test_fetch_listing_html_uses_explicit_company_fallback_chain(monkeypatch) -> assert ".details-offer-list" in wait_script +def test_fetch_listing_html_rejects_redirected_non_apec_urls(monkeypatch) -> None: + page = _FakeDetailPage({}, rendered_html="redirected") + + original_goto = page.goto + + def redirecting_goto(url: str, wait_until: str | None = None) -> None: + original_goto(url, wait_until=wait_until) + page.url = "https://www.apec.fr/candidat/recherche-emploi.html/emploi/recherche-avancee?error=true" + + monkeypatch.setattr(page, "goto", redirecting_goto) + monkeypatch.setattr(adapter_module, "_open_public_page", lambda: _fake_open_public_page(page)) + + with pytest.raises(ValueError, match="unexpected URL after redirects"): + ApecAdapter().fetch_listing_html( + "https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/111" + ) + + assert len(page.waited_functions) == 1 + assert page.goto_urls == ["https://www.apec.fr/candidat/recherche-emploi.html/emploi/detail-offre/111"] + + def test_fetch_listing_html_rejects_non_apec_hosts() -> None: adapter = ApecAdapter() @@ -524,12 +545,14 @@ def test_fetch_listing_html_reuses_browser_context_across_calls(monkeypatch) -> def __init__(self) -> None: self.goto_urls: list[str] = [] self.default_timeout: int | None = None + self.url = "" def set_default_timeout(self, timeout: int) -> None: self.default_timeout = timeout def goto(self, url: str, wait_until: str | None = None) -> None: self.goto_urls.append(url) + self.url = url def wait_for_function(self, function: str, polling: int | None = None, timeout: int | None = None) -> None: return None diff --git a/tests/apec/test_normalize.py b/tests/apec/test_normalize.py index 292f0ff..c474010 100644 --- a/tests/apec/test_normalize.py +++ b/tests/apec/test_normalize.py @@ -250,3 +250,43 @@ def test_normalize_apec_listing_records_warning_for_placeholder_company() -> Non assert listing.company is None assert listing.refreshed_at == "2026-06-02" assert [warning.field for warning in listing.warnings] == ["company"] + + +def test_normalize_apec_listing_records_warning_for_placeholder_description_text() -> None: + html = """ + + +
+

Data Engineer F/H

+
+
Ref. Apec : 178554452W
+ +

Publiée le 20/04/2026 Actualisée le 02/06/2026

+
+
+
+ CLOUD TEMPLE +
+
+
+

Descriptif du poste

+

N/A

+
+
+ + + """ + + listing = normalize_apec_listing( + url="https://example.test/job/123", + html=html, + fetched_at="2026-06-01T10:00:00Z", + source_job_id="job-123", + ) + + assert listing.description_text is None + assert [warning.field for warning in listing.warnings] == ["description_text"] diff --git a/tests/test_cli.py b/tests/test_cli.py index b7d408c..4b07999 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ from subprocess import run from textwrap import dedent +from pathlib import Path from typer.testing import CliRunner @@ -150,6 +151,78 @@ def test_build_profile_reports_when_no_warnings_are_included(tmp_path) -> None: assert "No warnings included." in result.stdout +def test_build_profile_reports_output_write_failures_cleanly(tmp_path, monkeypatch) -> None: + cv = tmp_path / "cv.txt" + cv.write_text( + dedent( + """ + Tonio Example + Location: France + Languages: French, English + Skills: Python, SQL + Data Engineer at Acme + Education: Master of Science at Example University + """ + ).strip(), + encoding="utf-8", + ) + profile = tmp_path / "profile.md" + profile.write_text( + dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer focused on Python and GCP. + + ## Target Roles + - Data Engineer + + ## Strengths + - Python + - SQL + + ## Skills To Emphasize + - GCP + - BigQuery + + ## Constraints + - CDI only + - France only + + ## Notes + - Slight preference for French listings. + """ + ).strip(), + encoding="utf-8", + ) + out = tmp_path / "candidate-profile.yaml" + + original_write_text = Path.write_text + + def flaky_write_text( + self: Path, + data: str, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> int: + if self == out: + raise OSError("disk full") + + return original_write_text(self, data, encoding=encoding, errors=errors, newline=newline) + + monkeypatch.setattr(Path, "write_text", flaky_write_text) + + result = CliRunner().invoke(app, ["build-profile", "--cv", str(cv), "--profile", str(profile), "--out", str(out)]) + + assert result.exit_code == 1 + assert not out.exists() + assert "Unable to write candidate profile to" in result.stderr + assert "disk full" in result.stderr + assert "Traceback" not in result.stderr + + def test_build_profile_rejects_empty_cv_text_before_writing(tmp_path) -> None: cv = tmp_path / "cv.txt" cv.write_text(" \n", encoding="utf-8")