Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
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 ' 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