# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : tests/test_connectors.py # Rôle : Tests de régression des connecteurs (rejeu des fixtures hors ligne) # Ré-enregistrer après un changement de site : run.py record # Créé : 2026-08-17 Modifié : 2026-09-16 # ============================================================================= import json import re import pytest from jobka.fixtures import FIXTURES_DIR, recorded_sources, replay_connector, snapshot SOURCES = recorded_sources() pytestmark = pytest.mark.skipif( not SOURCES, reason="aucune fixture enregistrée (run.py record )") @pytest.fixture(scope="module") def parsed(): """Rejoue chaque connecteur une seule fois par session de test.""" cache = {} def _get(source_id): if source_id not in cache: cache[source_id] = replay_connector(source_id).fetch() return cache[source_id] return _get @pytest.mark.parametrize("source_id", SOURCES) def test_parsing_matches_snapshot(source_id, parsed): """Le parsing des fixtures reproduit exactement l'instantané enregistré.""" expected = json.loads( (FIXTURES_DIR / source_id / "expected.json").read_text(encoding="utf-8")) actual = snapshot(parsed(source_id)) assert actual["count"] == expected["count"] assert actual["jobs"] == expected["jobs"] @pytest.mark.parametrize("source_id", SOURCES) def test_jobs_are_valid(source_id, parsed): """Contraintes de schéma : uid uniques, URLs absolues, salaires plausibles.""" postings = parsed(source_id) assert postings, "le connecteur ne retourne aucune offre depuis la fixture" uids = [p.uid for p in postings] assert len(uids) == len(set(uids)), "external_id en double" for p in postings: assert p.source == source_id assert p.external_id assert p.title, f"offre sans titre : {p.uid}" assert p.employer, f"offre sans employeur : {p.uid}" assert re.match(r"^https?://", p.url), f"URL non absolue : {p.url!r}" if p.salary_min is not None: assert p.salary_unit in ("hour", "day", "week", "biweek", "month", "year") assert p.salary_max is None or p.salary_max >= p.salary_min # jamais de valeur inventée : chaîne vide ou None, pas de « N/A » assert (p.employment_type or "") not in ("N/A", "n/a", "?") @pytest.mark.parametrize("source_id", SOURCES) def test_finalize_is_safe(source_id, parsed): """finalize() ne lève pas et produit des valeurs canoniques.""" for p in parsed(source_id): f = p.finalize() assert f.work_mode in (None, "presentiel", "hybride", "teletravail") assert f.employment_type in (None, "temps_plein", "temps_partiel", "contractuel", "stage", "saisonnier") if f.date_posted is not None: assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", f.date_posted) if f.lat is not None: assert 44.5 <= f.lat <= 63.0 and -80.0 <= f.lng <= -56.0 def test_njoyn_listing_retries_network_errors(monkeypatch): """Une panne réseau transitoire vers l'API Scrapfly (ConnectTimeout, vu le 2026-09-13 04:28 sur groupe_bellemare) doit engager le retry de _fetch_listing — nouvelle session Scrapfly, backoff — au lieu d'avorter le sync au premier essai ; après 3 échecs réseau, l'exception remonte.""" import requests from jobka.connectors.njoyn import NjoynConnector monkeypatch.setattr("jobka.connectors.njoyn.time.sleep", lambda s: None) conn = NjoynConnector() conn.source_id = "test_njoyn" conn.USE_SCRAPFLY = True calls = {"n": 0} def flaky_html(url): calls["n"] += 1 if calls["n"] < 3: raise requests.ConnectTimeout("api.scrapfly.io injoignable (simulé)") return "
" monkeypatch.setattr(conn, "_html", flaky_html) page, items = conn._fetch_listing() assert calls["n"] == 3 and items == [] conn2 = NjoynConnector() conn2.source_id = "test_njoyn" conn2.USE_SCRAPFLY = True def dead_html(url): raise requests.ConnectTimeout("api.scrapfly.io injoignable (simulé)") monkeypatch.setattr(conn2, "_html", dead_html) with pytest.raises(requests.ConnectTimeout): conn2._fetch_listing() def test_get_retries_transient_5xx(monkeypatch): """Un 5xx passager hors statuts anti-bot (502 unique de api.smartrecruiters.com sur la page 2 de talan le 2026-09-16, retour à la normale immédiat) doit être relancé en direct par le GET résilient au lieu d'avorter tout le sync — même logique que le POST (jci 2026-08-27). Après 3 échecs l'exception remonte ; un 4xx remonte immédiatement sans relance.""" import requests from jobka.connectors.base import BaseConnector monkeypatch.setattr("jobka.connectors.base.time.sleep", lambda s: None) def make_conn(statuses): conn = BaseConnector() calls = {"n": 0} def fake_get(url, timeout=None, **kw): resp = requests.Response() resp.status_code = statuses[min(calls["n"], len(statuses) - 1)] resp.url = url resp._content = b"ok" calls["n"] += 1 return resp monkeypatch.setattr(conn.session, "get", fake_get) return conn, calls # 502 ×2 puis 200 : le sync survit au hoquet serveur conn, calls = make_conn([502, 502, 200]) resp = conn.get("https://api.example.com/postings") assert resp.status_code == 200 and calls["n"] == 3 # 502 persistant : 3 tentatives puis l'erreur remonte conn2, calls2 = make_conn([502]) with pytest.raises(requests.HTTPError): conn2.get("https://api.example.com/postings") assert calls2["n"] == 3 # 404 (erreur métier) : aucune relance conn3, calls3 = make_conn([404]) with pytest.raises(requests.HTTPError): conn3.get("https://api.example.com/postings") assert calls3["n"] == 1 def test_successfactors_sitemap_guard(monkeypatch): """Une réponse 200 sans racine / (maintenance, anti-bot, corps vide — vu le 2026-09-13 06:14 sur alstom : found=0 unique contre une médiane de 224) doit engager le retry de _fetch_sitemap au lieu de passer pour un site sans offre ; une panne réseau transitoire est retentée ; après 3 réponses illisibles, le sync échoue explicitement.""" import requests from jobka.connectors.successfactors import SuccessFactorsConnector monkeypatch.setattr( "jobka.connectors.successfactors.time.sleep", lambda s: None) rss_ok = ("Poste (VILLE, QC, " "CA)https://x/job/p/1/" "") class _Resp: def __init__(self, text): self.text = text # 1. maintenance HTML puis panne réseau puis sitemap valide → retry gagne conn = SuccessFactorsConnector() conn.source_id = "test_sf" calls = {"n": 0} def flaky_get(url, **kw): calls["n"] += 1 if calls["n"] == 1: return _Resp("maintenance en cours") if calls["n"] == 2: raise requests.ConnectTimeout("site injoignable (simulé)") return _Resp(rss_ok) monkeypatch.setattr(conn, "get", flaky_get) assert conn._fetch_sitemap() == rss_ok and calls["n"] == 3 # 2. trois réponses illisibles → échec explicite, pas « 0 trouvé, ok » conn2 = SuccessFactorsConnector() conn2.source_id = "test_sf" monkeypatch.setattr(conn2, "get", lambda url, **kw: _Resp("")) with pytest.raises(RuntimeError, match="sitemap illisible"): conn2._fetch_sitemap() # 3. sitemap valide mais sans entrée après 3 essais → accepté (tenant vide) conn3 = SuccessFactorsConnector() conn3.source_id = "test_sf" empty_rss = "" monkeypatch.setattr(conn3, "get", lambda url, **kw: _Resp(empty_rss)) assert conn3._fetch_sitemap() == empty_rss assert conn3.fetch() == [] def test_lassonde_list_brightdata_fallback(monkeypatch): """Pendant un épisode Sucuri, la chaîne principale sert la même page 200 tronquée (sans `job-result-default` ni marqueur connu) à chaque essai — vu le 2026-09-14 08:17 : 3× « HTTP 200 via scrapfly, 4515 octets ». Le repli Bright Data (vendeur de déblocage différent) doit alors livrer la vraie liste ; s'il échoue aussi, l'erreur reste explicite (pas de faux « 0 offre ») ; et une liste saine ne le sollicite jamais.""" from jobka.connectors.lassonde import LassondeConnector monkeypatch.setattr("jobka.connectors.lassonde.time.sleep", lambda s: None) class _Resp: def __init__(self, text, status_code=200, via="scrapfly"): self.text = text self.status_code = status_code self.via = via truncated = "page tronquée sans section emplois" real_list = "
…35 cartes…
" # 1. chaîne principale tronquée → Bright Data livre la vraie page conn = LassondeConnector() conn.source_id = "test_lassonde" bd_calls = {"n": 0} monkeypatch.setattr(conn, "get_resilient", lambda url, **kw: _Resp(truncated)) def bd_ok(url, timeout, headers): bd_calls["n"] += 1 return _Resp(real_list, via="brightdata") monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", bd_ok) assert conn._fetch_list() == real_list and bd_calls["n"] == 1 # 2. Bright Data échoue aussi → erreur explicite après 3 essais conn2 = LassondeConnector() conn2.source_id = "test_lassonde" monkeypatch.setattr(conn2, "get_resilient", lambda url, **kw: _Resp(truncated)) monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", lambda url, timeout, headers: None) with pytest.raises(RuntimeError, match="job-result-default"): conn2._fetch_list() # 3. liste saine au premier essai → Bright Data jamais sollicité conn3 = LassondeConnector() conn3.source_id = "test_lassonde" monkeypatch.setattr(conn3, "get_resilient", lambda url, **kw: _Resp(real_list)) def bd_forbidden(url, timeout, headers): raise AssertionError("Bright Data ne doit pas être appelé") monkeypatch.setattr("jobka.connectors.lassonde._try_brightdata", bd_forbidden) assert conn3._fetch_list() == real_list def test_eq3_ltd_delisted_board_fallback(monkeypatch): """Le 2026-09-15, EQ3 a délisté son board Breezy (/json = [], sitemap vide) alors que les pages /p/ des offres restaient en ligne et ouvertes. Quand la liste est vide, le connecteur re-vérifie chaque offre active connue via sa page : une page vivante avec JSON-LD QC est conservée, une page « Position Closed » (fermeture vérifiée → retrait direct en base, malgré la garde anti-dérive) ou hors Québec est lâchée ; et une liste non vide ne déclenche jamais le repli.""" from jobka.connectors.eq3_ltd import Eq3LtdConnector class _Resp: def __init__(self, payload): self._payload = payload self.text = payload if isinstance(payload, str) else "" def json(self): return self._payload def _page(ld, closed=False): body = ('

Position Closed

' "

Sorry, this position is no longer accepting candidates." "

") if closed else "

offre

" return ('{body}") def _ld(title, city, region): return {"@context": "https://schema.org/", "@type": "JobPosting", "title": title, "description": "

desc

", "datePosted": "2026-08-26", "jobLocation": {"@type": "Place", "address": { "@type": "PostalAddress", "addressCountry": "CA", "addressRegion": region, "addressLocality": city}}} rows = [ {"uid": "eq3_ltd:aaa111", "external_id": "aaa111", "url": "https://x/p/aaa111-vivante", "title": "Associé(e) aux ventes", "city": "Brossard", "location_label": "Brossard, Quebec", "salary_label": "18 $ à 25 $/h", "date_posted": "2026-08-26", "details": '{"employment_label": ' '"Temps plein"}', "company_logo": ""}, {"uid": "eq3_ltd:bbb222", "external_id": "bbb222", "url": "https://x/p/bbb222-fermee", "title": "Fermée", "city": "Laval", "location_label": "Laval", "salary_label": "", "date_posted": None, "details": "{}", "company_logo": ""}, {"uid": "eq3_ltd:ccc333", "external_id": "ccc333", "url": "https://x/p/ccc333-denver", "title": "Hors QC", "city": "Denver", "location_label": "Denver, CO", "salary_label": "", "date_posted": None, "details": "{}", "company_logo": ""}, ] pages = { "https://x/p/aaa111-vivante": _page(_ld("Associé(e) aux ventes", "Brossard", "QC")), "https://x/p/bbb222-fermee": _page(_ld("Fermée", "Laval", "QC"), closed=True), "https://x/p/ccc333-denver": _page(_ld("Hors QC", "Denver", "CO")), } # 1. /json vide → repli : seule l'offre vivante et québécoise survit, # le libellé salarial connu est conservé ; l'offre « Position Closed » # est retirée directement en base (fermeture vérifiée à la source), # l'offre hors QC est simplement lâchée (pas de preuve de fermeture) conn = Eq3LtdConnector() conn.request_delay = 0 deactivated = [] monkeypatch.setattr(conn, "_known_active_rows", lambda: rows) monkeypatch.setattr(conn, "_deactivate_closed", deactivated.extend) monkeypatch.setattr(conn, "get", lambda url, **kw: _Resp( pages.get(url, []))) out = conn.fetch() assert [j.external_id for j in out] == ["aaa111"] assert out[0].title == "Associé(e) aux ventes" assert out[0].salary_label == "18 $ à 25 $/h" assert out[0].details["employment_label"] == "Temps plein" assert deactivated == ["eq3_ltd:bbb222"] # 2. liste non vide → chemin normal, le repli n'est jamais sollicité conn2 = Eq3LtdConnector() conn2.request_delay = 0 item = {"id": "ddd444", "name": "Poste liste", "url": "https://x/p/ddd444", "location": {"country": {"id": "CA"}, "state": {"id": "QC"}, "city": "Montréal"}, "type": {"id": "fullTime", "name": "Temps plein"}, "published_date": "2026-09-01", "description": "

d

"} monkeypatch.setattr(conn2, "get", lambda url, **kw: _Resp([item])) monkeypatch.setattr(conn2, "_known_active_rows", lambda: (_ for _ in () ).throw(AssertionError("repli interdit"))) out2 = conn2.fetch() assert [j.external_id for j in out2] == ["ddd444"] # 3. /json vide et aucune offre connue en base → 0, sans requête page conn3 = Eq3LtdConnector() conn3.request_delay = 0 monkeypatch.setattr(conn3, "get", lambda url, **kw: _Resp([])) monkeypatch.setattr(conn3, "_known_active_rows", lambda: []) assert conn3.fetch() == []