SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

20.7 KB · 422 lines python
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   tests/test_connectors.py4# Desc:   Tests des connecteurs sur fixtures réelles (réponses API figées) —5#         aucun appel réseau (CLAUDE.md §18).6# ==============================================================================7from __future__ import annotations89import json10from pathlib import Path1112from sortika.connectors import CONNECTORS13from sortika.connectors.montreal import MontrealConnector14from sortika.connectors.sitq import SitqConnector1516FIXTURES = Path(__file__).parent / "fixtures"171819def _patch_json(monkeypatch, connector, payloads):20    """Remplace get_json par une file de réponses figées."""21    queue = list(payloads)22    monkeypatch.setattr(connector, "get_json",23                        lambda *a, **kw: queue.pop(0) if queue else {"result": {}})242526def test_registry_autodiscovers():27    for sid in ("sitq", "montreal", "lepointdevente", "laval",28                "evenko", "atuvu", "lavitrine", "sherbrooke",29                "brossard", "ticketmaster", "bandsintown"):30        assert sid in CONNECTORS, sid313233def test_evenko_parses_fixture(monkeypatch):34    from sortika.connectors.evenko import EvenkoConnector35    c = EvenkoConnector()36    fixture = json.loads((FIXTURES / "evenko_sample.json").read_text(encoding="utf-8"))37    fixture["nbPages"] = 138    monkeypatch.setattr(c, "_search_page", lambda page: fixture if page == 0 else {"hits": []})39    events = [e.finalize() for e in c.fetch()]40    assert len(events) == 341    ev = events[0]42    assert ev.source == "evenko"43    assert ev.venue and ev.city and ev.region44    assert ev.start_date and ev.start_date.startswith("20")45    assert ev.url.startswith("http")          # billets Ticketmaster / salle46    assert "musique" in ev.categories or ev.categories47    # vague 2 : heure locale du show_time + artistes du champ headliners48    deep = next(e for e in events if "Deep Purple" in e.title)49    # Phase 2 : les premières parties (`supports`) s'ajoutent aux artistes50    assert deep.artists == ["Deep Purple", "Kansas", "Jefferson Starship"]51    assert deep.start_time and len(deep.start_time) == 5  # "HH:MM"52    # Phase 2 : tournée + heure des portes dans la description (factuels)53    myles = next(e for e in events if "Myles Smith" in e.title)54    assert "Tournée : My Mess, My Heart, My Life Tour" in myles.description55    assert "Portes :" in myles.description56    # representation_status ['buy_now'] = prévu → statut vide57    assert all(e.status == "" for e in events)585960def test_atuvu_parses_fixture():61    from sortika.connectors.atuvu import AtuvuConnector62    html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8")63    rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html)64    assert rows and rows[0]["external_id"] == "arkansas-2026-08-20"65    assert rows[0]["venue"] == "Église Saint-Antoine-de-Lavaltrie"66    assert rows[0]["city"] == "Lavaltrie"67    assert rows[0]["price_min"] == 27.0686970def test_sherbrooke_parses_fixture(monkeypatch):71    from sortika.connectors.sherbrooke import SherbrookeConnector72    c = SherbrookeConnector()73    html = (FIXTURES / "sherbrooke_page.html").read_text(encoding="utf-8")74    monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())75    events = [e.finalize() for e in c.fetch()]76    assert len(events) == 377    ev = events[0]78    assert ev.source == "sherbrooke"79    assert ev.city == "Sherbrooke" and ev.region == "Estrie"80    assert ev.start_date and ev.start_date.startswith("2026")81    assert ev.url.startswith("https://www.sherbrooke.ca/")82    assert ev.is_free in (True, False)   # booléen `payant` de la source83    assert any(e.image.startswith("https://www.sherbrooke.ca/Fichiers/")84               for e in events)858687def test_lavitrine_parses_fixture():88    from sortika.connectors.lavitrine import LaVitrineConnector89    html = (FIXTURES / "lavitrine_fiche.html").read_text(encoding="utf-8")90    rows = LaVitrineConnector()._parse_fiche(91        "https://www.lavitrine.com/fr/evenement/100-ans/23777", html)92    assert rows and rows[0]["start_date"] == "2026-11-22"93    assert rows[0]["venue"] == "Théâtre Petit Champlain"94    assert rows[0]["city"] == "Québec"95    assert rows[0]["external_id"].startswith("23777-")96    # Phase 2 : l'heure de la slide (« 15:00 HNE ») est enfin captée97    assert rows[0]["start_time"] == "15:00"98    # Phase 2 : description = accroche courte + début du texte À propos99    assert rows[0]["description"].startswith(100        "DE CHARLES TRENET À STROMAE")101    assert len(rows[0]["description"]) >= 200102103104def test_lepointdevente_parses_fixture():105    from sortika.connectors.lepointdevente import LePointDeVenteConnector106    html = (FIXTURES / "lepointdevente_page1.html").read_text(encoding="utf-8")107    events = [e.finalize() for e in LePointDeVenteConnector()._parse_page(html)]108    assert len(events) >= 40109    ev = next(e for e in events if "JOAT" in e.title)110    assert ev.city == "Montréal" and ev.region == "Montréal"111    assert ev.venue == "Club Soda"112    assert ev.start_date == "2026-09-06"113    # vague 2 : la carte de liste porte l'heure (« …, 20h00 ») quand la source114    # la publie (JOAT est multi-jours sans heure : start_time None, jamais inventé)115    assert ev.start_time is None116    assert sum(1 for e in events if e.start_time) >= 35117    assert all(len(e.start_time) == 5 for e in events if e.start_time)118    assert ev.url.startswith("https://lepointdevente.com/billets/")119    # dates FR parsées et régions rattachées hors Montréal aussi120    stq = next(e for e in events if e.city == "Saint-Agapit")121    assert stq.region == "Chaudière-Appalaches"122    # entités HTML décodées (pas de &#039; résiduel)123    assert not any("&#" in e.title for e in events)124125126def test_laval_parses_fixture(monkeypatch):127    from sortika.connectors.laval import LavalConnector128    c = LavalConnector()129    _patch_json(monkeypatch, c, [json.loads(130        (FIXTURES / "laval_sample.json").read_text(encoding="utf-8"))])131    events = [e.finalize() for e in c.fetch()]132    assert len(events) == 5133    ev = events[0]134    assert ev.source == "laval" and ev.region == "Laval"135    assert ev.url.startswith("https://www.laval.ca/")136    assert ev.start_date and ev.start_date.startswith("20")137    assert "<" not in ev.description  # HTML de l'Excerpt nettoyé138139140def test_sitq_parses_fixture(monkeypatch):141    c = SitqConnector()142    _patch_json(monkeypatch, c, [json.loads(143        (FIXTURES / "sitq_sample.json").read_text(encoding="utf-8"))])144    events = [e.finalize() for e in c.fetch()]145    # Phase 3 : les périodes multiples (PeriodeOuvertures[]) sont déroulées en146    # occurrences datées — la fixture porte 4 fiches dont une à 2 périodes147    assert len(events) == 5148    suffixed = [e for e in events if e.external_id.endswith("-p1")]149    assert len(suffixed) == 1150    base = next(e for e in events151                if e.external_id == suffixed[0].external_id[:-3])152    assert base.title == suffixed[0].title153    assert base.start_date != suffixed[0].start_date154    jack = next(e for e in events if e.title == "JACKALOPE")155    assert jack.uid == "sitq:EVENMTOV500NIO"156    assert jack.city == "Montréal"157    assert jack.region == "Montréal"158    assert jack.start_date == "2026-09-11" and jack.end_date == "2026-09-13"159    assert jack.lat and 45 < jack.lat < 46160    assert jack.website.startswith("http")161    assert "musique" in jack.categories or "sport" in jack.categories162163164def test_montreal_parses_fixture(monkeypatch):165    c = MontrealConnector()166    fixture = json.loads((FIXTURES / "montreal_sample.json").read_text(encoding="utf-8"))167    # une seule page : total = nb d'enregistrements de la fixture168    fixture["result"]["total"] = len(fixture["result"]["records"])169    _patch_json(monkeypatch, c, [fixture])170    events = [e.finalize() for e in c.fetch()]171    assert len(events) == 4172    ev = events[0]173    assert ev.source == "montreal"174    assert ev.city == "Montréal" and ev.region == "Montréal"175    assert ev.start_date and ev.start_date.startswith("20")176    assert ev.url.startswith("https://montreal.ca/")177    # « Gratuit » de la fixture → is_free True, jamais un prix inventé178    frees = [e for e in events if e.is_free]179    assert frees and all(e.price_min is None for e in frees)180181182def test_sherbrooke_times_converted_to_local(monkeypatch):183    """Les périodes UTC deviennent date + heure locales (placeholder = None)."""184    from sortika.connectors.sherbrooke import SherbrookeConnector185    c = SherbrookeConnector()186    html = (FIXTURES / "sherbrooke_page.html").read_text(encoding="utf-8")187    monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())188    events = [e.finalize() for e in c.fetch()]189    # fixture : 11:30:00Z (été) → 07:30 locale ; 00:00:00Z → 20:00 locale190    times = {e.start_time for e in events}191    assert "07:30" in times or "20:00" in times192193194def test_ticketmaster_without_key_scrapes_discover(monkeypatch):195    """Sans clé, le connecteur scrape les pages découverte (état cityEvents) :196    hit → Event avec date UTC convertie en locale, salle géolocalisée."""197    from sortika.connectors import ticketmaster as tm198    monkeypatch.delenv("TICKETMASTER_API_KEY", raising=False)199    hit = {200        "id": "310064A20EB06FDE", "title": "Mardi Latin Groove",201        "url": "https://www.ticketmaster.ca/x/event/310064A20EB06FDE",202        "dates": {"dateDisplay": "showDateTime",203                  "startDate": "2026-08-18T23:00:00Z"},204        "seatmapUrl": "https://mapsapi.tmol.io/maps/x.png",205        "venue": {"name": "Le Balcon X Terrasse", "city": "Montreal",206                  "state": "QC", "latitude": 45.50543786,207                  "longitude": -73.56881164,208                  "addressLineOne": "463 Rue Sainte-Catherine Ouest",209                  "code": "H3B1B1"},210        "majorCategory": {"id": "KZFzniwnSyZfZ7v7nJ"},211    }212    c = tm.TicketmasterConnector()213    monkeypatch.setattr(c, "_city_page",214                        lambda city, page: ([hit], 1) if page == 0 else ([], 1))215    events = [e.finalize() for e in c.fetch()]216    # 1 seul événement malgré les 3 villes crawlées (dédoublonnage par id)217    assert len(events) == 1218    ev = events[0]219    assert ev.external_id == "310064A20EB06FDE"220    assert ev.start_date == "2026-08-18" and ev.start_time == "19:00"  # UTC→locale221    # Phase 2 : « Montreal » (graphie source) → nom canonique MAMH222    assert ev.venue == "Le Balcon X Terrasse" and ev.city == "Montréal"223    assert ev.lat == 45.505438 and ev.region == "Montréal"224    assert ev.categories        # Concerts → taxonomie canonique225    assert ev.is_free is None and ev.price_min is None   # jamais inventé226227228def test_ticketmaster_scrape_keeps_cancelled_with_status_and_tba_time():229    """Phase 2 : un événement annulé est CONSERVÉ avec status=cancelled230    (bandeau frontend + eventStatus schema.org), plus jeté."""231    from sortika.connectors.ticketmaster import TicketmasterConnector232    c = TicketmasterConnector()233    ev = c._scrape_hit_to_event({"id": "x", "title": "T", "cancelled": True})234    assert ev is not None and ev.status == "cancelled"235    ev = c._scrape_hit_to_event({236        "id": "y", "title": "Sans heure annoncée",237        "dates": {"dateDisplay": "showDateOnly",238                  "startDate": "2026-09-01T04:00:00Z"}, "venue": {}})239    assert ev is not None and ev.start_time is None and ev.status == ""240241242def test_ticketmaster_parses_hit():243    """Parse d'un hit Discovery v2 (structure officielle documentée)."""244    from sortika.connectors.ticketmaster import TicketmasterConnector245    hit = {246        "id": "G5vYZ9x1", "name": "Les Cowboys Fringants",247        "url": "https://www.ticketmaster.ca/x/G5vYZ9x1",248        "dates": {"start": {"localDate": "2026-09-20", "localTime": "19:30:00"}},249        "classifications": [{"segment": {"name": "Music"},250                             "genre": {"name": "Rock"}}],251        "priceRanges": [{"min": 45.0, "max": 120.0}],252        "images": [{"ratio": "16_9", "width": 1024, "url": "https://img/x.jpg"},253                   {"ratio": "4_3", "width": 305, "url": "https://img/s.jpg"}],254        "_embedded": {255            "venues": [{"name": "Centre Bell",256                        "city": {"name": "Montréal"},257                        "postalCode": "H4B 5G0",258                        "address": {"line1": "1909 Avenue des Canadiens"},259                        "location": {"latitude": "45.496", "longitude": "-73.569"}}],260            "attractions": [{"name": "Les Cowboys Fringants"}]},261    }262    ev = TicketmasterConnector()._hit_to_event(hit).finalize()263    assert ev.uid == "ticketmaster:G5vYZ9x1"264    assert ev.start_date == "2026-09-20" and ev.start_time == "19:30"265    assert ev.venue == "Centre Bell" and ev.city == "Montréal"266    assert ev.lat and 45 < ev.lat < 46267    assert ev.price_min == 45.0 and "120" in ev.price_label268    assert ev.artists == ["Les Cowboys Fringants"]269    assert ev.image == "https://img/x.jpg"270    assert "musique" in ev.categories271272273def test_bandsintown_filters_quebec(monkeypatch):274    """Seules les dates au Québec sont gardées (structure API réelle 2026-08)."""275    from sortika.connectors.bandsintown import BandsintownConnector276    c = BandsintownConnector()277    payload = [278        {"id": "1038531745", "url": "https://www.bandsintown.com/e/1038531745",279         "datetime": "2026-08-18T18:45:00", "title": "",280         "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"},281         "venue": {"name": "RBC Amphitheatre", "city": "Toronto",282                   "country": "Canada", "region": "ON",283                   "latitude": "43.62", "longitude": "-79.41"},284         "lineup": ["Deep Purple"], "offers": [], "free": False},285        {"id": "1038531746", "url": "https://www.bandsintown.com/e/1038531746",286         "datetime": "2026-09-02T20:00:00", "title": "",287         "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"},288         "venue": {"name": "Centre Vidéotron", "city": "Québec",289                   "country": "Canada", "region": "QC",290                   "latitude": "46.828", "longitude": "-71.247"},291         "lineup": ["Deep Purple"], "offers": [], "free": False},292    ]293    monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload)294    rows = c._artist_events("Deep Purple")295    assert len(rows) == 1296    r = rows[0]297    assert r["city"] == "Québec" and r["venue"] == "Centre Vidéotron"298    assert r["date"] == "2026-09-02" and r["time"] == "20:00"299    assert r["lineup"] == ["Deep Purple"]300301302def test_atuvu_boilerplate_description_not_published():303    """La og:description gabarit (« trouvez des billets à tarif réduit… »)304    n'est jamais publiée comme description d'événement."""305    from sortika.connectors.atuvu import AtuvuConnector306    html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8")307    rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html)308    assert rows and rows[0]["description"] == ""309310311def test_laval_cancelled_prefix_becomes_status(monkeypatch):312    """« ANNULÉ - Titre » → status=cancelled, titre nettoyé (Phase 2)."""313    from sortika.connectors.laval import LavalConnector314    c = LavalConnector()315    rec = {"Title": "ANNULÉ - Rencontre avec Kevin Raphaël",316           "PageUrl": "/activites/rencontre.aspx",317           "EventDate": "2026-10-01T22:30:00", "EndDate": "2026-10-01T23:30:00"}318    monkeypatch.setattr(c, "get_json", lambda *a, **kw: [rec])319    events = [e.finalize() for e in c.fetch()]320    assert len(events) == 1321    assert events[0].status == "cancelled"322    assert events[0].title == "Rencontre avec Kevin Raphaël"323324325def test_eventbrite_result_to_event_end_time_status_organizer():326    """Champs Phase 2 du payload __SERVER_DATA__ réel : end_time, statut327    (is_cancelled conservé), full_description, organisateur du JSON-LD."""328    from sortika.connectors.eventbrite import EventbriteConnector329    r = {"id": "123", "name": "Atelier de poterie",330         "url": "https://www.eventbrite.ca/e/atelier-123",331         "summary": "court", "full_description": "Une description longue " * 20,332         "start_date": "2026-09-12", "start_time": "19:00",333         "end_date": "2026-09-12", "end_time": "21:30",334         "is_cancelled": True, "urgency_signals": {"messages": [], "categories": []},335         "primary_venue": {"name": "Studio X",336                           "address": {"region": "QC", "city": "Montréal",337                                       "address_1": "1 rue Test",338                                       "postal_code": "H2X 1Y6",339                                       "latitude": 45.5, "longitude": -73.6}}}340    ev = EventbriteConnector()._result_to_event(341        r, {"price": None, "organizer": "Les Ateliers MTL",342            "description": ""}).finalize()343    assert ev.end_time == "21:30"344    assert ev.status == "cancelled"          # conservé, plus jeté345    assert ev.organizer == "Les Ateliers MTL"346    assert len(ev.description) >= 200        # full_description > summary347    # summary court + description JSON-LD de la fiche → la plus riche gagne348    r2 = dict(r, full_description="")349    ev2 = EventbriteConnector()._result_to_event(350        r2, {"price": None, "organizer": "",351             "description": "Description détaillée de la fiche. " * 10})352    assert len(ev2.description) >= 200353354355def test_eventbrite_parse_detail_offers_and_organizer():356    from sortika.connectors.eventbrite import _parse_detail357    html = ('<script type="application/ld+json">{"@type":"Event",'358            '"organizer":{"@type":"Organization","name":"Prod QC"},'359            '"offers":[{"@type":"AggregateOffer","lowPrice":"25.00",'360            '"highPrice":"45.00","priceCurrency":"CAD"}]}</script>')361    d = _parse_detail(html)362    assert d["organizer"] == "Prod QC"363    assert d["price"] == {"low": 25.0, "high": 45.0, "cur": "CAD"}364    assert d["description"] == ""            # clé toujours présente365366367def test_bandsintown_offers_ticket_url_and_soldout(monkeypatch):368    from sortika.connectors.bandsintown import BandsintownConnector369    c = BandsintownConnector()370    payload = [{"id": "9", "url": "https://www.bandsintown.com/e/9",371                "datetime": "2026-09-02T20:00:00", "title": "",372                "artist": {"name": "X"}, "lineup": ["X"],373                "offers": [{"type": "Tickets", "status": "sold out",374                            "url": "https://billets.example/x"}],375                "venue": {"name": "Salle Y", "city": "Québec",376                          "country": "Canada", "region": "QC"}}]377    monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload)378    rows = c._artist_events("X")379    assert rows[0]["url"] == "https://billets.example/x"380    assert rows[0]["offer_status"] == "sold out"381382383def test_longueuil_image_from_nuxt_state():384    from sortika.connectors.longueuil import _IMG_RES, _js_str385    state = ('x:{bg:"https:\\u002F\\u002Fcms.longueuil.quebec\\u002Fsites'386             '\\u002Fdefault\\u002Ffiles\\u002Fstyles\\u002Ffront_medium_16_9'387             '\\u002Fpublic\\u002Fmedias\\u002Fimages\\u002F2022-03'388             '\\u002Fcvl231121-04h_0.jpg?itok=GYMG8rcf"}')389    m = _IMG_RES[0].search(state)390    assert m391    url = _js_str(m.group(1))392    assert url.startswith("https://cms.longueuil.quebec/sites/")393    assert "front_medium_16_9" in url and url.endswith("itok=GYMG8rcf")394395396def test_brossard_parses_fiche_banner_and_jsonld():397    from sortika.connectors.brossard import BrossardConnector398    c = BrossardConnector()399    # bannière des fiches ville (structure réelle brossard.ca, 2026-08)400    ville = ('<div class="d-flex align-items-center"> Cet événement aura lieu '401             'le 6 décembre 2026, de 15 h 00 à 16 h 00 </div>'402             '<meta property="og:image" content="https://brossard.ca/i.jpg">')403    info = c._parse_fiche(ville)404    assert info["start_date"] == "2026-12-06" and info["time"] == "15:00"405    assert info["end_time"] == "16:00"       # Phase 2 : « de 15 h 00 à 16 h 00 »406    assert info["image"] == "https://brossard.ca/i.jpg"407    # JSON-LD Event des fiches biblio.brossard.ca (structure réelle, 2026-08)408    biblio = ('<script type="application/ld+json">[{"@context":"http://schema.org",'409              '"@type":"Event","name":"La marche : un chemin de santé",'410              '"startDate":"2026-11-27T13:30:00","endDate":"2026-11-27T15:00:00",'411              '"image":"https://biblio.brossard.ca/g.jpg",'412              '"location":[{"@type":"Place","name":"Salle d\'animation",'413              '"address":{"@type":"PostalAddress",'414              '"streetAddress":"7855, av San Francisco","postalCode":"J4X 2A4"}}]}]'415              '</script>')416    info = c._parse_fiche(biblio)417    assert info["start_date"] == "2026-11-27" and info["time"] == "13:30"418    assert info["end_date"] == "2026-11-27"419    assert info["end_time"] == "15:00"       # Phase 2 : endDate JSON-LD420    assert info["venue"] == "Salle d'animation"421    assert info["postal_code"] == "J4X 2A4"422
Files · 38ca85b245a225b74dd035352f0b773e8bd5388e
Menu

Theme