SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
15.3 KB · 373 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : tests/test_connectors.py6# Rôle    : Tests de régression des connecteurs (rejeu des fixtures hors ligne)7#           Ré-enregistrer après un changement de site : run.py record <source>8# Créé    : 2026-08-17   Modifié : 2026-09-169# =============================================================================10import json11import re1213import pytest1415from jobka.fixtures import FIXTURES_DIR, recorded_sources, replay_connector, snapshot1617SOURCES = recorded_sources()1819pytestmark = pytest.mark.skipif(20    not SOURCES, reason="aucune fixture enregistrée (run.py record <source>)")212223@pytest.fixture(scope="module")24def parsed():25    """Rejoue chaque connecteur une seule fois par session de test."""26    cache = {}2728    def _get(source_id):29        if source_id not in cache:30            cache[source_id] = replay_connector(source_id).fetch()31        return cache[source_id]3233    return _get343536@pytest.mark.parametrize("source_id", SOURCES)37def test_parsing_matches_snapshot(source_id, parsed):38    """Le parsing des fixtures reproduit exactement l'instantané enregistré."""39    expected = json.loads(40        (FIXTURES_DIR / source_id / "expected.json").read_text(encoding="utf-8"))41    actual = snapshot(parsed(source_id))42    assert actual["count"] == expected["count"]43    assert actual["jobs"] == expected["jobs"]444546@pytest.mark.parametrize("source_id", SOURCES)47def test_jobs_are_valid(source_id, parsed):48    """Contraintes de schéma : uid uniques, URLs absolues, salaires plausibles."""49    postings = parsed(source_id)50    assert postings, "le connecteur ne retourne aucune offre depuis la fixture"5152    uids = [p.uid for p in postings]53    assert len(uids) == len(set(uids)), "external_id en double"5455    for p in postings:56        assert p.source == source_id57        assert p.external_id58        assert p.title, f"offre sans titre : {p.uid}"59        assert p.employer, f"offre sans employeur : {p.uid}"60        assert re.match(r"^https?://", p.url), f"URL non absolue : {p.url!r}"61        if p.salary_min is not None:62            assert p.salary_unit in ("hour", "day", "week", "biweek", "month", "year")63            assert p.salary_max is None or p.salary_max >= p.salary_min64        # jamais de valeur inventée : chaîne vide ou None, pas de « N/A »65        assert (p.employment_type or "") not in ("N/A", "n/a", "?")666768@pytest.mark.parametrize("source_id", SOURCES)69def test_finalize_is_safe(source_id, parsed):70    """finalize() ne lève pas et produit des valeurs canoniques."""71    for p in parsed(source_id):72        f = p.finalize()73        assert f.work_mode in (None, "presentiel", "hybride", "teletravail")74        assert f.employment_type in (None, "temps_plein", "temps_partiel",75                                     "contractuel", "stage", "saisonnier")76        if f.date_posted is not None:77            assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", f.date_posted)78        if f.lat is not None:79            assert 44.5 <= f.lat <= 63.0 and -80.0 <= f.lng <= -56.0808182def test_njoyn_listing_retries_network_errors(monkeypatch):83    """Une panne réseau transitoire vers l'API Scrapfly (ConnectTimeout, vu le84    2026-09-13 04:28 sur groupe_bellemare) doit engager le retry de85    _fetch_listing — nouvelle session Scrapfly, backoff — au lieu d'avorter le86    sync au premier essai ; après 3 échecs réseau, l'exception remonte."""87    import requests8889    from jobka.connectors.njoyn import NjoynConnector9091    monkeypatch.setattr("jobka.connectors.njoyn.time.sleep", lambda s: None)9293    conn = NjoynConnector()94    conn.source_id = "test_njoyn"95    conn.USE_SCRAPFLY = True96    calls = {"n": 0}9798    def flaky_html(url):99        calls["n"] += 1100        if calls["n"] < 3:101            raise requests.ConnectTimeout("api.scrapfly.io injoignable (simulé)")102        return "<form action='xweb.asp'></form>"103104    monkeypatch.setattr(conn, "_html", flaky_html)105    page, items = conn._fetch_listing()106    assert calls["n"] == 3 and items == []107108    conn2 = NjoynConnector()109    conn2.source_id = "test_njoyn"110    conn2.USE_SCRAPFLY = True111112    def dead_html(url):113        raise requests.ConnectTimeout("api.scrapfly.io injoignable (simulé)")114115    monkeypatch.setattr(conn2, "_html", dead_html)116    with pytest.raises(requests.ConnectTimeout):117        conn2._fetch_listing()118119120def test_get_retries_transient_5xx(monkeypatch):121    """Un 5xx passager hors statuts anti-bot (502 unique de122    api.smartrecruiters.com sur la page 2 de talan le 2026-09-16, retour à la123    normale immédiat) doit être relancé en direct par le GET résilient au lieu124    d'avorter tout le sync — même logique que le POST (jci 2026-08-27). Après125    3 échecs l'exception remonte ; un 4xx remonte immédiatement sans relance."""126    import requests127128    from jobka.connectors.base import BaseConnector129130    monkeypatch.setattr("jobka.connectors.base.time.sleep", lambda s: None)131132    def make_conn(statuses):133        conn = BaseConnector()134        calls = {"n": 0}135136        def fake_get(url, timeout=None, **kw):137            resp = requests.Response()138            resp.status_code = statuses[min(calls["n"], len(statuses) - 1)]139            resp.url = url140            resp._content = b"ok"141            calls["n"] += 1142            return resp143144        monkeypatch.setattr(conn.session, "get", fake_get)145        return conn, calls146147    # 502 ×2 puis 200 : le sync survit au hoquet serveur148    conn, calls = make_conn([502, 502, 200])149    resp = conn.get("https://api.example.com/postings")150    assert resp.status_code == 200 and calls["n"] == 3151152    # 502 persistant : 3 tentatives puis l'erreur remonte153    conn2, calls2 = make_conn([502])154    with pytest.raises(requests.HTTPError):155        conn2.get("https://api.example.com/postings")156    assert calls2["n"] == 3157158    # 404 (erreur métier) : aucune relance159    conn3, calls3 = make_conn([404])160    with pytest.raises(requests.HTTPError):161        conn3.get("https://api.example.com/postings")162    assert calls3["n"] == 1163164165def test_successfactors_sitemap_guard(monkeypatch):166    """Une réponse 200 sans racine <rss>/<urlset> (maintenance, anti-bot,167    corps vide — vu le 2026-09-13 06:14 sur alstom : found=0 unique contre168    une médiane de 224) doit engager le retry de _fetch_sitemap au lieu de169    passer pour un site sans offre ; une panne réseau transitoire est170    retentée ; après 3 réponses illisibles, le sync échoue explicitement."""171    import requests172173    from jobka.connectors.successfactors import SuccessFactorsConnector174175    monkeypatch.setattr(176        "jobka.connectors.successfactors.time.sleep", lambda s: None)177178    rss_ok = ("<rss version=\"2.0\"><channel><item><title>Poste (VILLE, QC, "179              "CA)</title><link>https://x/job/p/1/</link></item></channel>"180              "</rss>")181182    class _Resp:183        def __init__(self, text):184            self.text = text185186    # 1. maintenance HTML puis panne réseau puis sitemap valide → retry gagne187    conn = SuccessFactorsConnector()188    conn.source_id = "test_sf"189    calls = {"n": 0}190191    def flaky_get(url, **kw):192        calls["n"] += 1193        if calls["n"] == 1:194            return _Resp("<html>maintenance en cours</html>")195        if calls["n"] == 2:196            raise requests.ConnectTimeout("site injoignable (simulé)")197        return _Resp(rss_ok)198199    monkeypatch.setattr(conn, "get", flaky_get)200    assert conn._fetch_sitemap() == rss_ok and calls["n"] == 3201202    # 2. trois réponses illisibles → échec explicite, pas « 0 trouvé, ok »203    conn2 = SuccessFactorsConnector()204    conn2.source_id = "test_sf"205    monkeypatch.setattr(conn2, "get", lambda url, **kw: _Resp(""))206    with pytest.raises(RuntimeError, match="sitemap illisible"):207        conn2._fetch_sitemap()208209    # 3. sitemap valide mais sans entrée après 3 essais → accepté (tenant vide)210    conn3 = SuccessFactorsConnector()211    conn3.source_id = "test_sf"212    empty_rss = "<rss version=\"2.0\"><channel></channel></rss>"213    monkeypatch.setattr(conn3, "get", lambda url, **kw: _Resp(empty_rss))214    assert conn3._fetch_sitemap() == empty_rss215    assert conn3.fetch() == []216217218def test_lassonde_list_brightdata_fallback(monkeypatch):219    """Pendant un épisode Sucuri, la chaîne principale sert la même page 200220    tronquée (sans `job-result-default` ni marqueur connu) à chaque essai —221    vu le 2026-09-14 08:17 : 3× « HTTP 200 via scrapfly, 4515 octets ». Le222    repli Bright Data (vendeur de déblocage différent) doit alors livrer la223    vraie liste ; s'il échoue aussi, l'erreur reste explicite (pas de faux224    « 0 offre ») ; et une liste saine ne le sollicite jamais."""225    from jobka.connectors.lassonde import LassondeConnector226227    monkeypatch.setattr("jobka.connectors.lassonde.time.sleep", lambda s: None)228229    class _Resp:230        def __init__(self, text, status_code=200, via="scrapfly"):231            self.text = text232            self.status_code = status_code233            self.via = via234235    truncated = "<html><body>page tronquée sans section emplois</body></html>"236    real_list = "<div class='job-result-default'>…35 cartes…</div>"237238    # 1. chaîne principale tronquée → Bright Data livre la vraie page239    conn = LassondeConnector()240    conn.source_id = "test_lassonde"241    bd_calls = {"n": 0}242    monkeypatch.setattr(conn, "get_resilient",243                        lambda url, **kw: _Resp(truncated))244245    def bd_ok(url, timeout, headers):246        bd_calls["n"] += 1247        return _Resp(real_list, via="brightdata")248249    monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", bd_ok)250    assert conn._fetch_list() == real_list and bd_calls["n"] == 1251252    # 2. Bright Data échoue aussi → erreur explicite après 3 essais253    conn2 = LassondeConnector()254    conn2.source_id = "test_lassonde"255    monkeypatch.setattr(conn2, "get_resilient",256                        lambda url, **kw: _Resp(truncated))257    monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata",258                        lambda url, timeout, headers: None)259    with pytest.raises(RuntimeError, match="job-result-default"):260        conn2._fetch_list()261262    # 3. liste saine au premier essai → Bright Data jamais sollicité263    conn3 = LassondeConnector()264    conn3.source_id = "test_lassonde"265    monkeypatch.setattr(conn3, "get_resilient",266                        lambda url, **kw: _Resp(real_list))267268    def bd_forbidden(url, timeout, headers):269        raise AssertionError("Bright Data ne doit pas être appelé")270271    monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata",272                        bd_forbidden)273    assert conn3._fetch_list() == real_list274275276def test_eq3_ltd_delisted_board_fallback(monkeypatch):277    """Le 2026-09-15, EQ3 a délisté son board Breezy (/json = [], sitemap278    vide) alors que les pages /p/ des offres restaient en ligne et ouvertes.279    Quand la liste est vide, le connecteur re-vérifie chaque offre active280    connue via sa page : une page vivante avec JSON-LD QC est conservée,281    une page « Position Closed » (fermeture vérifiée → retrait direct en282    base, malgré la garde anti-dérive) ou hors Québec est lâchée ; et une283    liste non vide ne déclenche jamais le repli."""284    from jobka.connectors.eq3_ltd import Eq3LtdConnector285286    class _Resp:287        def __init__(self, payload):288            self._payload = payload289            self.text = payload if isinstance(payload, str) else ""290291        def json(self):292            return self._payload293294    def _page(ld, closed=False):295        body = ('<div class="application-confirmed"><h1>Position Closed</h1>'296                "<p>Sorry, this position is no longer accepting candidates."297                "</p></div>") if closed else "<h1>offre</h1>"298        return ('<html><script type="application/ld+json">'299                + json.dumps(ld) + f"</script>{body}</html>")300301    def _ld(title, city, region):302        return {"@context": "https://schema.org/", "@type": "JobPosting",303                "title": title, "description": "<p>desc</p>",304                "datePosted": "2026-08-26",305                "jobLocation": {"@type": "Place", "address": {306                    "@type": "PostalAddress", "addressCountry": "CA",307                    "addressRegion": region, "addressLocality": city}}}308309    rows = [310        {"uid": "eq3_ltd:aaa111", "external_id": "aaa111",311         "url": "https://x/p/aaa111-vivante",312         "title": "Associé(e) aux ventes", "city": "Brossard",313         "location_label": "Brossard, Quebec", "salary_label": "18 $ à 25 $/h",314         "date_posted": "2026-08-26", "details": '{"employment_label": '315         '"Temps plein"}', "company_logo": ""},316        {"uid": "eq3_ltd:bbb222", "external_id": "bbb222",317         "url": "https://x/p/bbb222-fermee",318         "title": "Fermée", "city": "Laval", "location_label": "Laval",319         "salary_label": "", "date_posted": None, "details": "{}",320         "company_logo": ""},321        {"uid": "eq3_ltd:ccc333", "external_id": "ccc333",322         "url": "https://x/p/ccc333-denver",323         "title": "Hors QC", "city": "Denver", "location_label": "Denver, CO",324         "salary_label": "", "date_posted": None, "details": "{}",325         "company_logo": ""},326    ]327    pages = {328        "https://x/p/aaa111-vivante":329            _page(_ld("Associé(e) aux ventes", "Brossard", "QC")),330        "https://x/p/bbb222-fermee":331            _page(_ld("Fermée", "Laval", "QC"), closed=True),332        "https://x/p/ccc333-denver": _page(_ld("Hors QC", "Denver", "CO")),333    }334335    # 1. /json vide → repli : seule l'offre vivante et québécoise survit,336    #    le libellé salarial connu est conservé ; l'offre « Position Closed »337    #    est retirée directement en base (fermeture vérifiée à la source),338    #    l'offre hors QC est simplement lâchée (pas de preuve de fermeture)339    conn = Eq3LtdConnector()340    conn.request_delay = 0341    deactivated = []342    monkeypatch.setattr(conn, "_known_active_rows", lambda: rows)343    monkeypatch.setattr(conn, "_deactivate_closed", deactivated.extend)344    monkeypatch.setattr(conn, "get", lambda url, **kw: _Resp(345        pages.get(url, [])))346    out = conn.fetch()347    assert [j.external_id for j in out] == ["aaa111"]348    assert out[0].title == "Associé(e) aux ventes"349    assert out[0].salary_label == "18 $ à 25 $/h"350    assert out[0].details["employment_label"] == "Temps plein"351    assert deactivated == ["eq3_ltd:bbb222"]352353    # 2. liste non vide → chemin normal, le repli n'est jamais sollicité354    conn2 = Eq3LtdConnector()355    conn2.request_delay = 0356    item = {"id": "ddd444", "name": "Poste liste", "url": "https://x/p/ddd444",357            "location": {"country": {"id": "CA"}, "state": {"id": "QC"},358                         "city": "Montréal"},359            "type": {"id": "fullTime", "name": "Temps plein"},360            "published_date": "2026-09-01", "description": "<p>d</p>"}361    monkeypatch.setattr(conn2, "get", lambda url, **kw: _Resp([item]))362    monkeypatch.setattr(conn2, "_known_active_rows", lambda: (_ for _ in ()363                        ).throw(AssertionError("repli interdit")))364    out2 = conn2.fetch()365    assert [j.external_id for j in out2] == ["ddd444"]366367    # 3. /json vide et aucune offre connue en base → 0, sans requête page368    conn3 = Eq3LtdConnector()369    conn3.request_delay = 0370    monkeypatch.setattr(conn3, "get", lambda url, **kw: _Resp([]))371    monkeypatch.setattr(conn3, "_known_active_rows", lambda: [])372    assert conn3.fetch() == []373