SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
8.2 KB · 251 lines python
Raw Blame History
1# ============================================2# Projet   : API-KA3# Fichier  : tests/test_collectors.py4# Node     : m3u96b5# Author   : Simon-Pierre Boucher6# Contact  : contact@spboucher.ai7# Date     : 2026-08-168# ============================================9"""Tests des collecteurs : pipeline complet, déduplication, échecs et statut retried."""1011from __future__ import annotations1213import datetime1415import pytest16from sqlalchemy import func, select1718from src.collectors import COLLECTORS, build_collectors, get_collector19from src.collectors.louka_collector import LoukaCollector20from src.config import SERVICES21from src.database.db import session_scope22from src.database.models import DATA_MODELS, CollectionRun2324SAMPLE_PAYLOAD = [25    {"id": 1, "nom": "enregistrement-a"},26    {"id": 2, "nom": "enregistrement-b"},27]282930def _count_rows(service: str, date_key: datetime.date) -> int:31    model = DATA_MODELS[service]32    with session_scope() as session:33        return (34            session.execute(35                select(func.count())36                .select_from(model)37                .where(model.source == service, model.date_key == date_key)38            ).scalar()39            or 040        )414243def _last_run(service: str, date_key: datetime.date) -> CollectionRun | None:44    with session_scope() as session:45        return (46            session.execute(47                select(CollectionRun)48                .where(49                    CollectionRun.service == service, CollectionRun.date_key == date_key50                )51                .order_by(CollectionRun.id.desc())52                .limit(1)53            )54            .scalars()55            .first()56        )575859def test_registry_covers_all_services() -> None:60    """Chaque service KA a un collecteur enregistré."""61    assert set(COLLECTORS) == set(SERVICES)62    assert len(build_collectors()) == 963    with pytest.raises(ValueError):64        get_collector("inconnu")656667def test_run_success(monkeypatch: pytest.MonkeyPatch) -> None:68    """Le pipeline complet insère les données et journalise un run success."""69    date_key = datetime.date(2026, 8, 1)70    collector = LoukaCollector(attempts=1, delays=(0,))71    monkeypatch.setattr(collector, "fetch", lambda: SAMPLE_PAYLOAD)7273    result = collector.run(date_key=date_key)7475    assert result["status"] == "success"76    assert result["records_count"] == 277    assert _count_rows("louka", date_key) == 278    run = _last_run("louka", date_key)79    assert run is not None80    assert run.status == "success"81    assert run.records_count == 282    assert run.node838485def test_run_deduplicates_by_checksum(monkeypatch: pytest.MonkeyPatch) -> None:86    """Une seconde collecte du même payload n'insère aucun doublon."""87    date_key = datetime.date(2026, 8, 2)88    collector = LoukaCollector(attempts=1, delays=(0,))89    monkeypatch.setattr(collector, "fetch", lambda: SAMPLE_PAYLOAD)9091    first = collector.run(date_key=date_key)92    second = collector.run(date_key=date_key)9394    assert first["records_count"] == 295    assert second["status"] == "success"96    assert second["records_count"] == 097    assert _count_rows("louka", date_key) == 29899100def test_run_failure_after_retries(monkeypatch: pytest.MonkeyPatch) -> None:101    """Après épuisement des relances : run failed + message d'erreur journalisé."""102    date_key = datetime.date(2026, 8, 3)103    collector = LoukaCollector(attempts=2, delays=(0, 0))104105    def broken() -> None:106        raise RuntimeError("source indisponible")107108    monkeypatch.setattr(collector, "fetch", broken)109110    result = collector.run(date_key=date_key)111112    assert result["status"] == "failed"113    assert "source indisponible" in (result["error"] or "")114    run = _last_run("louka", date_key)115    assert run is not None116    assert run.status == "failed"117    assert run.records_count == 0118    assert "source indisponible" in (run.error_message or "")119120121def test_run_retried_status(monkeypatch: pytest.MonkeyPatch) -> None:122    """Un succès après relance est journalisé avec le statut retried."""123    date_key = datetime.date(2026, 8, 4)124    collector = LoukaCollector(attempts=2, delays=(0, 0))125    calls = {"count": 0}126127    def flaky() -> list[dict]:128        calls["count"] += 1129        if calls["count"] == 1:130            raise RuntimeError("transitoire")131        return SAMPLE_PAYLOAD132133    monkeypatch.setattr(collector, "fetch", flaky)134135    result = collector.run(date_key=date_key)136137    assert result["status"] == "retried"138    assert result["records_count"] == 2139    run = _last_run("louka", date_key)140    assert run is not None141    assert run.status == "retried"142143144def test_validate_rejects_empty_payload() -> None:145    """Un payload vide ou None est rejeté par la validation."""146    collector = LoukaCollector(attempts=1, delays=(0,))147    with pytest.raises(ValueError):148        collector.validate(None)149    with pytest.raises(ValueError):150        collector.validate([])151152153def test_checksum_is_stable_and_order_insensitive() -> None:154    """Le checksum SHA-256 est canonique (indépendant de l'ordre des clés)."""155    a = LoukaCollector.checksum({"x": 1, "y": 2})156    b = LoukaCollector.checksum({"y": 2, "x": 1})157    c = LoukaCollector.checksum({"x": 1, "y": 3})158    assert a == b159    assert a != c160    assert len(a) == 64161162163# ------------------------------------------------------------------- job-ka164165166def test_jobka_pipeline_inserts_into_jobka_data(167    monkeypatch: pytest.MonkeyPatch,168) -> None:169    """Le collecteur job-ka insère dans jobka_data et journalise un run success."""170    from src.collectors.jobka_collector import JobkaCollector171172    date_key = datetime.date(2026, 8, 5)173    collector = JobkaCollector(attempts=1, delays=(0,))174    monkeypatch.setattr(175        collector,176        "fetch",177        lambda: [178            {"uid": "a1", "title": "Analyste", "employer": "Ville de Québec"},179            {"uid": "b2", "title": "Technicien", "employer": "CHU de Québec"},180        ],181    )182183    result = collector.run(date_key=date_key)184185    assert result["service"] == "jobka"186    assert result["status"] == "success"187    assert result["records_count"] == 2188    assert _count_rows("jobka", date_key) == 2189    run = _last_run("jobka", date_key)190    assert run is not None191    assert run.status == "success"192193194def test_jobka_fetch_paginates_offset_until_total(195    monkeypatch: pytest.MonkeyPatch,196) -> None:197    """fetch() pagine /api/jobs (limit/offset, clé "jobs") jusqu'au total annoncé."""198    from src.collectors import base_collector199    from src.collectors.jobka_collector import JobkaCollector200201    total = 7202    jobs = [{"uid": f"job-{i}", "title": f"Poste {i}"} for i in range(total)]203    calls: list[dict] = []204205    class FakeResponse:206        def __init__(self, offset: int, limit: int) -> None:207            self._batch = jobs[offset : offset + limit]208209        def raise_for_status(self) -> None:210            return None211212        def json(self) -> dict:213            return {"total": total, "count": len(self._batch), "jobs": self._batch}214215    class FakeClient:216        def __init__(self, *args, **kwargs) -> None:217            pass218219        def __enter__(self) -> "FakeClient":220            return self221222        def __exit__(self, *exc) -> None:223            return None224225        def get(self, url: str, params: dict) -> FakeResponse:226            calls.append({"url": url, **params})227            return FakeResponse(params["offset"], params["limit"])228229    monkeypatch.setattr(base_collector.httpx, "Client", FakeClient)230    collector = JobkaCollector(attempts=1, delays=(0,))231    monkeypatch.setattr(type(collector), "page_size", 3)232233    items = collector.fetch()234235    assert [i["uid"] for i in items] == [f"job-{i}" for i in range(total)]236    # 3 pages : offsets 0, 3, 6 — l'arrêt vient du total annoncé par l'API.237    assert [c["offset"] for c in calls] == [0, 3, 6]238    assert all(c["limit"] == 3 for c in calls)239240241def test_jobka_registered_with_expected_settings() -> None:242    """job-ka est enregistré dans le registre avec la bonne configuration."""243    from src.collectors.jobka_collector import JobkaCollector244245    assert COLLECTORS["jobka"] is JobkaCollector246    collector = get_collector("jobka")247    assert collector.service == "jobka"248    assert collector.items_key == "jobs"249    assert collector.pagination == "offset"250    assert DATA_MODELS["jobka"].__tablename__ == "jobka_data"251