Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
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 _no_enrich(monkeypatch, connector):27 """Neutralise cache disque + fetch parallèle : aucun appel réseau et28 aucune écriture de data/<source>_cache.json pendant les tests."""29 monkeypatch.setattr(connector, "load_cache", lambda: {})30 monkeypatch.setattr(connector, "save_cache", lambda cache: None)31 monkeypatch.setattr(connector, "fetch_many",32 lambda urls, worker, max_workers=6: {})333435def test_registry_autodiscovers():36 for sid in ("sitq", "montreal", "lepointdevente", "laval",37 "evenko", "atuvu", "lavitrine", "sherbrooke",38 "brossard", "ticketmaster", "bandsintown"):39 assert sid in CONNECTORS, sid404142def test_evenko_parses_fixture(monkeypatch):43 from sortika.connectors.evenko import EvenkoConnector44 c = EvenkoConnector()45 fixture = json.loads((FIXTURES / "evenko_sample.json").read_text(encoding="utf-8"))46 fixture["nbPages"] = 147 monkeypatch.setattr(c, "_search_page", lambda page: fixture if page == 0 else {"hits": []})48 events = [e.finalize() for e in c.fetch()]49 assert len(events) == 350 ev = events[0]51 assert ev.source == "evenko"52 assert ev.venue and ev.city and ev.region53 assert ev.start_date and ev.start_date.startswith("20")54 assert ev.url.startswith("http") # billets Ticketmaster / salle55 assert "musique" in ev.categories or ev.categories56 # vague 2 : heure locale du show_time + artistes du champ headliners57 deep = next(e for e in events if "Deep Purple" in e.title)58 # Phase 2 : les premières parties (`supports`) s'ajoutent aux artistes59 assert deep.artists == ["Deep Purple", "Kansas", "Jefferson Starship"]60 assert deep.start_time and len(deep.start_time) == 5 # "HH:MM"61 # Phase 2 : tournée + heure des portes dans la description (factuels)62 myles = next(e for e in events if "Myles Smith" in e.title)63 assert "Tournée : My Mess, My Heart, My Life Tour" in myles.description64 assert "Portes :" in myles.description65 # representation_status ['buy_now'] = prévu → statut vide66 assert all(e.status == "" for e in events)676869def test_atuvu_parses_fixture():70 from sortika.connectors.atuvu import AtuvuConnector71 html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8")72 rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html)73 assert rows and rows[0]["external_id"] == "arkansas-2026-08-20"74 assert rows[0]["venue"] == "Église Saint-Antoine-de-Lavaltrie"75 assert rows[0]["city"] == "Lavaltrie"76 assert rows[0]["price_min"] == 27.0777879def test_sherbrooke_parses_fixture(monkeypatch):80 from sortika.connectors.sherbrooke import SherbrookeConnector81 c = SherbrookeConnector()82 html = (FIXTURES / "sherbrooke_page.html").read_text(encoding="utf-8")83 monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())84 events = [e.finalize() for e in c.fetch()]85 assert len(events) == 386 ev = events[0]87 assert ev.source == "sherbrooke"88 assert ev.city == "Sherbrooke" and ev.region == "Estrie"89 assert ev.start_date and ev.start_date.startswith("2026")90 assert ev.url.startswith("https://www.sherbrooke.ca/")91 assert ev.is_free in (True, False) # booléen `payant` de la source92 assert any(e.image.startswith("https://www.sherbrooke.ca/Fichiers/")93 for e in events)949596def test_lavitrine_parses_fixture():97 from sortika.connectors.lavitrine import LaVitrineConnector98 html = (FIXTURES / "lavitrine_fiche.html").read_text(encoding="utf-8")99 rows = LaVitrineConnector()._parse_fiche(100 "https://www.lavitrine.com/fr/evenement/100-ans/23777", html)101 assert rows and rows[0]["start_date"] == "2026-11-22"102 assert rows[0]["venue"] == "Théâtre Petit Champlain"103 assert rows[0]["city"] == "Québec"104 assert rows[0]["external_id"].startswith("23777-")105 # Phase 2 : l'heure de la slide (« 15:00 HNE ») est enfin captée106 assert rows[0]["start_time"] == "15:00"107 # Phase 2 : description = accroche courte + début du texte À propos108 assert rows[0]["description"].startswith(109 "DE CHARLES TRENET À STROMAE")110 assert len(rows[0]["description"]) >= 200111112113def test_lepointdevente_parses_fixture():114 from sortika.connectors.lepointdevente import LePointDeVenteConnector115 html = (FIXTURES / "lepointdevente_page1.html").read_text(encoding="utf-8")116 events = [e.finalize() for e in LePointDeVenteConnector()._parse_page(html)]117 assert len(events) >= 40118 ev = next(e for e in events if "JOAT" in e.title)119 assert ev.city == "Montréal" and ev.region == "Montréal"120 assert ev.venue == "Club Soda"121 assert ev.start_date == "2026-09-06"122 # vague 2 : la carte de liste porte l'heure (« …, 20h00 ») quand la source123 # la publie (JOAT est multi-jours sans heure : start_time None, jamais inventé)124 assert ev.start_time is None125 assert sum(1 for e in events if e.start_time) >= 35126 assert all(len(e.start_time) == 5 for e in events if e.start_time)127 assert ev.url.startswith("https://lepointdevente.com/billets/")128 # dates FR parsées et régions rattachées hors Montréal aussi129 stq = next(e for e in events if e.city == "Saint-Agapit")130 assert stq.region == "Chaudière-Appalaches"131 # entités HTML décodées (pas de ' résiduel)132 assert not any("&#" in e.title for e in events)133134135def test_laval_parses_fixture(monkeypatch):136 from sortika.connectors.laval import LavalConnector137 c = LavalConnector()138 _patch_json(monkeypatch, c, [json.loads(139 (FIXTURES / "laval_sample.json").read_text(encoding="utf-8"))])140 events = [e.finalize() for e in c.fetch()]141 assert len(events) == 5142 ev = events[0]143 assert ev.source == "laval" and ev.region == "Laval"144 assert ev.url.startswith("https://www.laval.ca/")145 assert ev.start_date and ev.start_date.startswith("20")146 assert "<" not in ev.description # HTML de l'Excerpt nettoyé147148149def test_sitq_parses_fixture(monkeypatch):150 c = SitqConnector()151 _no_enrich(monkeypatch, c) # enrichissement des sites : pas de réseau152 _patch_json(monkeypatch, c, [json.loads(153 (FIXTURES / "sitq_sample.json").read_text(encoding="utf-8"))])154 events = [e.finalize() for e in c.fetch()]155 # Phase 3 : les périodes multiples (PeriodeOuvertures[]) sont déroulées en156 # occurrences datées — la fixture porte 4 fiches dont une à 2 périodes157 assert len(events) == 5158 suffixed = [e for e in events if e.external_id.endswith("-p1")]159 assert len(suffixed) == 1160 base = next(e for e in events161 if e.external_id == suffixed[0].external_id[:-3])162 assert base.title == suffixed[0].title163 assert base.start_date != suffixed[0].start_date164 jack = next(e for e in events if e.title == "JACKALOPE")165 assert jack.uid == "sitq:EVENMTOV500NIO"166 assert jack.city == "Montréal"167 assert jack.region == "Montréal"168 assert jack.start_date == "2026-09-11" and jack.end_date == "2026-09-13"169 assert jack.lat and 45 < jack.lat < 46170 assert jack.website.startswith("http")171 assert "musique" in jack.categories or "sport" in jack.categories172173174def test_montreal_parses_fixture(monkeypatch):175 c = MontrealConnector()176 _no_enrich(monkeypatch, c) # enrichissement des fiches : pas de réseau177 fixture = json.loads((FIXTURES / "montreal_sample.json").read_text(encoding="utf-8"))178 # une seule page : total = nb d'enregistrements de la fixture179 fixture["result"]["total"] = len(fixture["result"]["records"])180 _patch_json(monkeypatch, c, [fixture])181 events = [e.finalize() for e in c.fetch()]182 assert len(events) == 4183 ev = events[0]184 assert ev.source == "montreal"185 assert ev.city == "Montréal" and ev.region == "Montréal"186 assert ev.start_date and ev.start_date.startswith("20")187 assert ev.url.startswith("https://montreal.ca/")188 # « Gratuit » de la fixture → is_free True, jamais un prix inventé189 frees = [e for e in events if e.is_free]190 assert frees and all(e.price_min is None for e in frees)191192193def test_sherbrooke_times_converted_to_local(monkeypatch):194 """Les périodes UTC deviennent date + heure locales (placeholder = None)."""195 from sortika.connectors.sherbrooke import SherbrookeConnector196 c = SherbrookeConnector()197 html = (FIXTURES / "sherbrooke_page.html").read_text(encoding="utf-8")198 monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())199 events = [e.finalize() for e in c.fetch()]200 # fixture : 11:30:00Z (été) → 07:30 locale ; 00:00:00Z → 20:00 locale201 times = {e.start_time for e in events}202 assert "07:30" in times or "20:00" in times203204205def test_ticketmaster_without_key_scrapes_discover(monkeypatch):206 """Sans clé, le connecteur scrape les pages découverte (état cityEvents) :207 hit → Event avec date UTC convertie en locale, salle géolocalisée."""208 from sortika.connectors import ticketmaster as tm209 monkeypatch.delenv("TICKETMASTER_API_KEY", raising=False)210 hit = {211 "id": "310064A20EB06FDE", "title": "Mardi Latin Groove",212 "url": "https://www.ticketmaster.ca/x/event/310064A20EB06FDE",213 "dates": {"dateDisplay": "showDateTime",214 "startDate": "2026-08-18T23:00:00Z"},215 "seatmapUrl": "https://mapsapi.tmol.io/maps/x.png",216 "venue": {"name": "Le Balcon X Terrasse", "city": "Montreal",217 "state": "QC", "latitude": 45.50543786,218 "longitude": -73.56881164,219 "addressLineOne": "463 Rue Sainte-Catherine Ouest",220 "code": "H3B1B1"},221 "majorCategory": {"id": "KZFzniwnSyZfZ7v7nJ"},222 }223 c = tm.TicketmasterConnector()224 monkeypatch.setattr(c, "_city_page",225 lambda city, page: ([hit], 1) if page == 0 else ([], 1))226 events = [e.finalize() for e in c.fetch()]227 # 1 seul événement malgré les 3 villes crawlées (dédoublonnage par id)228 assert len(events) == 1229 ev = events[0]230 assert ev.external_id == "310064A20EB06FDE"231 assert ev.start_date == "2026-08-18" and ev.start_time == "19:00" # UTC→locale232 # Phase 2 : « Montreal » (graphie source) → nom canonique MAMH233 assert ev.venue == "Le Balcon X Terrasse" and ev.city == "Montréal"234 assert ev.lat == 45.505438 and ev.region == "Montréal"235 assert ev.categories # Concerts → taxonomie canonique236 assert ev.is_free is None and ev.price_min is None # jamais inventé237238239def test_ticketmaster_scrape_keeps_cancelled_with_status_and_tba_time():240 """Phase 2 : un événement annulé est CONSERVÉ avec status=cancelled241 (bandeau frontend + eventStatus schema.org), plus jeté."""242 from sortika.connectors.ticketmaster import TicketmasterConnector243 c = TicketmasterConnector()244 ev = c._scrape_hit_to_event({"id": "x", "title": "T", "cancelled": True})245 assert ev is not None and ev.status == "cancelled"246 ev = c._scrape_hit_to_event({247 "id": "y", "title": "Sans heure annoncée",248 "dates": {"dateDisplay": "showDateOnly",249 "startDate": "2026-09-01T04:00:00Z"}, "venue": {}})250 assert ev is not None and ev.start_time is None and ev.status == ""251252253def test_ticketmaster_parses_hit():254 """Parse d'un hit Discovery v2 (structure officielle documentée)."""255 from sortika.connectors.ticketmaster import TicketmasterConnector256 hit = {257 "id": "G5vYZ9x1", "name": "Les Cowboys Fringants",258 "url": "https://www.ticketmaster.ca/x/G5vYZ9x1",259 "dates": {"start": {"localDate": "2026-09-20", "localTime": "19:30:00"}},260 "classifications": [{"segment": {"name": "Music"},261 "genre": {"name": "Rock"}}],262 "priceRanges": [{"min": 45.0, "max": 120.0}],263 "images": [{"ratio": "16_9", "width": 1024, "url": "https://img/x.jpg"},264 {"ratio": "4_3", "width": 305, "url": "https://img/s.jpg"}],265 "_embedded": {266 "venues": [{"name": "Centre Bell",267 "city": {"name": "Montréal"},268 "postalCode": "H4B 5G0",269 "address": {"line1": "1909 Avenue des Canadiens"},270 "location": {"latitude": "45.496", "longitude": "-73.569"}}],271 "attractions": [{"name": "Les Cowboys Fringants"}]},272 }273 ev = TicketmasterConnector()._hit_to_event(hit).finalize()274 assert ev.uid == "ticketmaster:G5vYZ9x1"275 assert ev.start_date == "2026-09-20" and ev.start_time == "19:30"276 assert ev.venue == "Centre Bell" and ev.city == "Montréal"277 assert ev.lat and 45 < ev.lat < 46278 assert ev.price_min == 45.0 and "120" in ev.price_label279 assert ev.artists == ["Les Cowboys Fringants"]280 assert ev.image == "https://img/x.jpg"281 assert "musique" in ev.categories282283284def test_bandsintown_filters_quebec(monkeypatch):285 """Seules les dates au Québec sont gardées (structure API réelle 2026-08)."""286 from sortika.connectors.bandsintown import BandsintownConnector287 c = BandsintownConnector()288 payload = [289 {"id": "1038531745", "url": "https://www.bandsintown.com/e/1038531745",290 "datetime": "2026-08-18T18:45:00", "title": "",291 "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"},292 "venue": {"name": "RBC Amphitheatre", "city": "Toronto",293 "country": "Canada", "region": "ON",294 "latitude": "43.62", "longitude": "-79.41"},295 "lineup": ["Deep Purple"], "offers": [], "free": False},296 {"id": "1038531746", "url": "https://www.bandsintown.com/e/1038531746",297 "datetime": "2026-09-02T20:00:00", "title": "",298 "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"},299 "venue": {"name": "Centre Vidéotron", "city": "Québec",300 "country": "Canada", "region": "QC",301 "latitude": "46.828", "longitude": "-71.247"},302 "lineup": ["Deep Purple"], "offers": [], "free": False},303 ]304 monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload)305 rows = c._artist_events("Deep Purple")306 assert len(rows) == 1307 r = rows[0]308 assert r["city"] == "Québec" and r["venue"] == "Centre Vidéotron"309 assert r["date"] == "2026-09-02" and r["time"] == "20:00"310 assert r["lineup"] == ["Deep Purple"]311312313def test_atuvu_boilerplate_description_not_published():314 """La og:description gabarit (« trouvez des billets à tarif réduit… »)315 n'est jamais publiée comme description d'événement."""316 from sortika.connectors.atuvu import AtuvuConnector317 html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8")318 rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html)319 assert rows and rows[0]["description"] == ""320321322def test_laval_cancelled_prefix_becomes_status(monkeypatch):323 """« ANNULÉ - Titre » → status=cancelled, titre nettoyé (Phase 2)."""324 from sortika.connectors.laval import LavalConnector325 c = LavalConnector()326 rec = {"Title": "ANNULÉ - Rencontre avec Kevin Raphaël",327 "PageUrl": "/activites/rencontre.aspx",328 "EventDate": "2026-10-01T22:30:00", "EndDate": "2026-10-01T23:30:00"}329 monkeypatch.setattr(c, "get_json", lambda *a, **kw: [rec])330 events = [e.finalize() for e in c.fetch()]331 assert len(events) == 1332 assert events[0].status == "cancelled"333 assert events[0].title == "Rencontre avec Kevin Raphaël"334335336def test_eventbrite_result_to_event_end_time_status_organizer():337 """Champs Phase 2 du payload __SERVER_DATA__ réel : end_time, statut338 (is_cancelled conservé), full_description, organisateur du JSON-LD."""339 from sortika.connectors.eventbrite import EventbriteConnector340 r = {"id": "123", "name": "Atelier de poterie",341 "url": "https://www.eventbrite.ca/e/atelier-123",342 "summary": "court", "full_description": "Une description longue " * 20,343 "start_date": "2026-09-12", "start_time": "19:00",344 "end_date": "2026-09-12", "end_time": "21:30",345 "is_cancelled": True, "urgency_signals": {"messages": [], "categories": []},346 "primary_venue": {"name": "Studio X",347 "address": {"region": "QC", "city": "Montréal",348 "address_1": "1 rue Test",349 "postal_code": "H2X 1Y6",350 "latitude": 45.5, "longitude": -73.6}}}351 ev = EventbriteConnector()._result_to_event(352 r, {"price": None, "organizer": "Les Ateliers MTL",353 "description": ""}).finalize()354 assert ev.end_time == "21:30"355 assert ev.status == "cancelled" # conservé, plus jeté356 assert ev.organizer == "Les Ateliers MTL"357 assert len(ev.description) >= 200 # full_description > summary358 # summary court + description JSON-LD de la fiche → la plus riche gagne359 r2 = dict(r, full_description="")360 ev2 = EventbriteConnector()._result_to_event(361 r2, {"price": None, "organizer": "",362 "description": "Description détaillée de la fiche. " * 10})363 assert len(ev2.description) >= 200364365366def test_eventbrite_parse_detail_offers_and_organizer():367 from sortika.connectors.eventbrite import _parse_detail368 html = ('<script type="application/ld+json">{"@type":"Event",'369 '"organizer":{"@type":"Organization","name":"Prod QC"},'370 '"offers":[{"@type":"AggregateOffer","lowPrice":"25.00",'371 '"highPrice":"45.00","priceCurrency":"CAD"}]}</script>')372 d = _parse_detail(html)373 assert d["organizer"] == "Prod QC"374 assert d["price"] == {"low": 25.0, "high": 45.0, "cur": "CAD"}375 assert d["description"] == "" # clé toujours présente376377378def test_bandsintown_offers_ticket_url_and_soldout(monkeypatch):379 from sortika.connectors.bandsintown import BandsintownConnector380 c = BandsintownConnector()381 payload = [{"id": "9", "url": "https://www.bandsintown.com/e/9",382 "datetime": "2026-09-02T20:00:00", "title": "",383 "artist": {"name": "X"}, "lineup": ["X"],384 "offers": [{"type": "Tickets", "status": "sold out",385 "url": "https://billets.example/x"}],386 "venue": {"name": "Salle Y", "city": "Québec",387 "country": "Canada", "region": "QC"}}]388 monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload)389 rows = c._artist_events("X")390 assert rows[0]["url"] == "https://billets.example/x"391 assert rows[0]["offer_status"] == "sold out"392393394def test_longueuil_image_from_nuxt_state():395 from sortika.connectors.longueuil import _IMG_RES, _js_str396 state = ('x:{bg:"https:\\u002F\\u002Fcms.longueuil.quebec\\u002Fsites'397 '\\u002Fdefault\\u002Ffiles\\u002Fstyles\\u002Ffront_medium_16_9'398 '\\u002Fpublic\\u002Fmedias\\u002Fimages\\u002F2022-03'399 '\\u002Fcvl231121-04h_0.jpg?itok=GYMG8rcf"}')400 m = _IMG_RES[0].search(state)401 assert m402 url = _js_str(m.group(1))403 assert url.startswith("https://cms.longueuil.quebec/sites/")404 assert "front_medium_16_9" in url and url.endswith("itok=GYMG8rcf")405406407def test_brossard_parses_fiche_banner_and_jsonld():408 from sortika.connectors.brossard import BrossardConnector409 c = BrossardConnector()410 # bannière des fiches ville (structure réelle brossard.ca, 2026-08)411 ville = ('<div class="d-flex align-items-center"> Cet événement aura lieu '412 'le 6 décembre 2026, de 15 h 00 à 16 h 00 </div>'413 '<meta property="og:image" content="https://brossard.ca/i.jpg">')414 info = c._parse_fiche(ville)415 assert info["start_date"] == "2026-12-06" and info["time"] == "15:00"416 assert info["end_time"] == "16:00" # Phase 2 : « de 15 h 00 à 16 h 00 »417 assert info["image"] == "https://brossard.ca/i.jpg"418 # JSON-LD Event des fiches biblio.brossard.ca (structure réelle, 2026-08)419 biblio = ('<script type="application/ld+json">[{"@context":"http://schema.org",'420 '"@type":"Event","name":"La marche : un chemin de santé",'421 '"startDate":"2026-11-27T13:30:00","endDate":"2026-11-27T15:00:00",'422 '"image":"https://biblio.brossard.ca/g.jpg",'423 '"location":[{"@type":"Place","name":"Salle d\'animation",'424 '"address":{"@type":"PostalAddress",'425 '"streetAddress":"7855, av San Francisco","postalCode":"J4X 2A4"}}]}]'426 '</script>')427 info = c._parse_fiche(biblio)428 assert info["start_date"] == "2026-11-27" and info["time"] == "13:30"429 assert info["end_date"] == "2026-11-27"430 assert info["end_time"] == "15:00" # Phase 2 : endDate JSON-LD431 assert info["venue"] == "Salle d'animation"432 assert info["postal_code"] == "J4X 2A4"433434435# -- Vague « données ouvertes / agrégateurs » 2026-08-25 ----------------------436437def test_atuvu_multiseances_same_day_suffixed():438 """2 représentations le même jour = 2 événements distincts : la 1ʳᵉ garde439 l'id historique (zéro churn), la 2ᵉ est suffixée par son heure — avant,440 les external_id identiques s'écrasaient à l'ingestion (fiche réelle441 We call it Ballet, 3 dates × 2 séances, figée 2026-08-25)."""442 from sortika.connectors.atuvu import AtuvuConnector443 html = (FIXTURES / "atuvu_fiche_multiseances.html").read_text(encoding="utf-8")444 rows = AtuvuConnector()._parse_fiche(445 "https://atuvu.ca/billets-spectacle/"446 "we-call-it-ballet-la-belle-au-bois-dormant-danse-et-spectacle-de-lumiere",447 html)448 assert len(rows) == 6449 ids = [r["external_id"] for r in rows]450 assert len(set(ids)) == 6 # plus aucun doublon d'id451 sep12 = [r for r in rows if r["start_date"] == "2026-09-12"]452 assert sep12[0]["external_id"].endswith("-2026-09-12") # id historique453 assert sep12[1]["external_id"].endswith("-2026-09-12-1830")454 assert sep12[0]["start_time"] == "16:00"455 assert sep12[1]["start_time"] == "18:30"456 assert all(r["venue"] == "Théâtre Rialto" for r in rows)457458459def test_atuvu_fixture_time_and_price_still_parsed():460 """La fiche arkansas (heure « - 20h00 » et prix .price-reg) reste bien461 parsée par le parseur v2 — c'est le backfill de ces champs que la462 version « v » du cache déclenche."""463 from sortika.connectors.atuvu import AtuvuConnector464 html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8")465 rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html)466 assert rows[0]["start_time"] == "20:00"467 assert rows[0]["price_min"] == 27.0468469470def test_quebecanimee_fiche_lieu_adresse_heures():471 """La fiche publie le lieu réel (.lieu-nom), l'adresse civique472 (.lieu-adresse) et l'heure par représentation (data-date/data-heure) —473 fiche réelle figée 2026-08-25. L'ancien motif générique attrapait le474 menu « Lieux » du site (venue 100 % bruit)."""475 from sortika.connectors.quebecanimee import QuebecAnimeeConnector476 html = (FIXTURES / "quebecanimee_fiche.html").read_text(encoding="utf-8")477 det = QuebecAnimeeConnector()._parse_fiche(html)478 assert det["venue"] == "Maison Tessier-Dit-Laplante"479 assert det["address"] == "2328, avenue Royale"480 assert det["times"] == {"2026-08-30": "13:30"}481 assert det["v"] >= 2482 assert det["venue"] != "Lieux"483484485def test_lepointdevente_detail_address_microdata():486 """La fiche /billets/<id> publie l'adresse civique du lieu en microdonnées487 schema.org (streetAddress + addressLocality) — fiche réelle figée488 2026-08-25 → géocodage aval possible (GPS était à 30 %)."""489 from sortika.connectors.lepointdevente import LePointDeVenteConnector490 html = (FIXTURES / "lepointdevente_fiche.html").read_text(encoding="utf-8")491 info = LePointDeVenteConnector()._parse_detail(html)492 assert info["address"] == "6 rue de l'Église"493 assert info["city"] == "Sainte-Thérèse"494495496def test_bandsintown_image_recovered_from_sibling_event(monkeypatch):497 """L'objet `artist` est un dict VIDE sur certains événements (constaté498 2026-08-25 sur les dates QC — cause de l'image à 8 %) : l'image est499 reprise d'un autre événement de la même réponse."""500 from sortika.connectors.bandsintown import BandsintownConnector501 c = BandsintownConnector()502 payload = [503 {"id": "1", "datetime": "2026-09-25T19:30:00",504 "artist": {"name": "PW", "image_url": "https://photos/pw.jpeg"},505 "venue": {"name": "RBG", "city": "Burlington",506 "country": "Canada", "region": "ON"}},507 {"id": "2", "datetime": "2026-10-02T20:00:00", "artist": {},508 "venue": {"name": "MTELUS", "city": "Montréal",509 "country": "Canada", "region": "QC"}},510 ]511 monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload)512 rows = c._artist_events("PW")513 assert len(rows) == 1 and rows[0]["venue"] == "MTELUS"514 assert rows[0]["image"] == "https://photos/pw.jpeg"515516517def test_bandsintown_image_profile_fallback(monkeypatch):518 """Aucune image dans toute la réponse mais des dates QC : 1 GET de repli519 sur le profil /artists/{artiste} (image_url canonique)."""520 from sortika.connectors.bandsintown import BandsintownConnector521 c = BandsintownConnector()522 events = [{"id": "2", "datetime": "2026-10-02T20:00:00", "artist": {},523 "venue": {"name": "MTELUS", "city": "Montréal",524 "country": "Canada", "region": "QC"}}]525 profile = {"name": "Down", "image_url": "https://photos/down.jpeg"}526 _patch_json(monkeypatch, c, [events, profile])527 rows = c._artist_events("Down")528 assert rows[0]["image"] == "https://photos/down.jpeg"529530531def test_sitq_site_og_image_and_description():532 """Le site officiel d'un événement SITQ livre og:image ET og:description533 (page réelle festivalmigrateurs.com figée 2026-08-25) — le flux Tourinsoft534 ne publie ni photo ni texte descriptif."""535 from sortika.connectors.sitq import _parse_site536 html = (FIXTURES / "sitq_site.html").read_text(encoding="utf-8")537 info = _parse_site(html)538 assert info["image"].startswith("https://festivalmigrateurs.com/")539 assert info["desc"].startswith("Cet automne, levez les yeux vers le ciel")540 assert info["v"] >= 2541542543def test_sitq_site_short_slogan_not_a_description():544 """Un og:description trop court (slogan / nom de site) n'est jamais545 publié comme description."""546 from sortika.connectors.sitq import _parse_site547 info = _parse_site('<meta property="og:image" content="//x.com/i.jpg">'548 '<meta property="og:description" content="Accueil">')549 assert info["image"] == "https://x.com/i.jpg"550 assert info["desc"] == ""551552553def test_lavitrine_representation_jsonld_address_gps_price():554 """Les pages « représentation » du sitemap (…/<ficheId>/<dateId>)555 publient un JSON-LD Event complet : salle, adresse civique, code postal,556 GPS et prix billetterie — attachés à la représentation COURANTE (jamais557 listée dans son propre carrousel), date+heure LOCALES du fil d'Ariane558 (le startDate JSON-LD est en UTC : 17 sept 20:00 HAE → 18 sept 00:00 Z).559 Page réelle figée 2026-08-25."""560 from sortika.connectors.lavitrine import LaVitrineConnector561 html = (FIXTURES / "lavitrine_fiche_representation.html").read_text(encoding="utf-8")562 rows = LaVitrineConnector()._parse_fiche(563 "https://www.lavitrine.com/fr/evenement/"564 "jf-pauze-les-amours-de-seconde-main/22040/1559973", html)565 assert len(rows) == 25 # 24 slides de tournée + la rep courante566 rep = [r for r in rows if r.get("address")]567 assert len(rep) == 1568 r = rep[0]569 assert r["external_id"] == "22040-2026-09-17" # même schéma que les slides570 assert (r["start_date"], r["start_time"]) == ("2026-09-17", "20:00")571 assert r["venue"] == "Salle Odyssée"572 assert r["address"] == "855 Bd de la Gappe"573 assert (r["city"], r["postal_code"]) == ("Gatineau", "J8T 8H9")574 assert (r["lat"], r["lng"]) == (45.4845871, -75.6819682)575 assert r["price_min"] == 51.0576 assert "billetterie lavitrine.com" in r["price_label"]577 # le slug lisible (pas l'id numérique « 22040 ») sert de catégorie brute578 assert all("22040" not in c for c in r["raw_categories"])579580581def test_lavitrine_fiche_fixture_still_parsed():582 """Non-régression v3 : la fiche classique (sans JSON-LD) donne toujours583 ses slides datées avec heure et salle, sans adresse inventée."""584 from sortika.connectors.lavitrine import LaVitrineConnector585 html = (FIXTURES / "lavitrine_fiche.html").read_text(encoding="utf-8")586 rows = LaVitrineConnector()._parse_fiche(587 "https://www.lavitrine.com/fr/evenement/100-ans/23777", html)588 assert rows and rows[0]["external_id"] == "23777-2026-11-22"589 assert rows[0]["start_time"] == "15:00"590 assert rows[0]["venue"] == "Théâtre Petit Champlain"591 assert not rows[0].get("address")592 assert rows[0].get("price_min") is None593