SPB Git forge

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.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
29.3 KB · 593 lines python
Raw Blame History
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 &#039; 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