HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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