# ============================================================================== # Author: Simon-Pierre Boucher # File: tests/test_connectors.py # Desc: Tests des connecteurs sur fixtures réelles (réponses API figées) — # aucun appel réseau (CLAUDE.md §18). # ============================================================================== from __future__ import annotations import json from pathlib import Path from sortika.connectors import CONNECTORS from sortika.connectors.montreal import MontrealConnector from sortika.connectors.sitq import SitqConnector FIXTURES = Path(__file__).parent / "fixtures" def _patch_json(monkeypatch, connector, payloads): """Remplace get_json par une file de réponses figées.""" queue = list(payloads) monkeypatch.setattr(connector, "get_json", lambda *a, **kw: queue.pop(0) if queue else {"result": {}}) def _no_enrich(monkeypatch, connector): """Neutralise cache disque + fetch parallèle : aucun appel réseau et aucune écriture de data/_cache.json pendant les tests.""" monkeypatch.setattr(connector, "load_cache", lambda: {}) monkeypatch.setattr(connector, "save_cache", lambda cache: None) monkeypatch.setattr(connector, "fetch_many", lambda urls, worker, max_workers=6: {}) def test_registry_autodiscovers(): for sid in ("sitq", "montreal", "lepointdevente", "laval", "evenko", "atuvu", "lavitrine", "sherbrooke", "brossard", "ticketmaster", "bandsintown"): assert sid in CONNECTORS, sid def test_evenko_parses_fixture(monkeypatch): from sortika.connectors.evenko import EvenkoConnector c = EvenkoConnector() fixture = json.loads((FIXTURES / "evenko_sample.json").read_text(encoding="utf-8")) fixture["nbPages"] = 1 monkeypatch.setattr(c, "_search_page", lambda page: fixture if page == 0 else {"hits": []}) events = [e.finalize() for e in c.fetch()] assert len(events) == 3 ev = events[0] assert ev.source == "evenko" assert ev.venue and ev.city and ev.region assert ev.start_date and ev.start_date.startswith("20") assert ev.url.startswith("http") # billets Ticketmaster / salle assert "musique" in ev.categories or ev.categories # vague 2 : heure locale du show_time + artistes du champ headliners deep = next(e for e in events if "Deep Purple" in e.title) # Phase 2 : les premières parties (`supports`) s'ajoutent aux artistes assert deep.artists == ["Deep Purple", "Kansas", "Jefferson Starship"] assert deep.start_time and len(deep.start_time) == 5 # "HH:MM" # Phase 2 : tournée + heure des portes dans la description (factuels) myles = next(e for e in events if "Myles Smith" in e.title) assert "Tournée : My Mess, My Heart, My Life Tour" in myles.description assert "Portes :" in myles.description # representation_status ['buy_now'] = prévu → statut vide assert all(e.status == "" for e in events) def test_atuvu_parses_fixture(): from sortika.connectors.atuvu import AtuvuConnector html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8") rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html) assert rows and rows[0]["external_id"] == "arkansas-2026-08-20" assert rows[0]["venue"] == "Église Saint-Antoine-de-Lavaltrie" assert rows[0]["city"] == "Lavaltrie" assert rows[0]["price_min"] == 27.0 def test_sherbrooke_parses_fixture(monkeypatch): from sortika.connectors.sherbrooke import SherbrookeConnector c = SherbrookeConnector() html = (FIXTURES / "sherbrooke_page.html").read_text(encoding="utf-8") monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})()) events = [e.finalize() for e in c.fetch()] assert len(events) == 3 ev = events[0] assert ev.source == "sherbrooke" assert ev.city == "Sherbrooke" and ev.region == "Estrie" assert ev.start_date and ev.start_date.startswith("2026") assert ev.url.startswith("https://www.sherbrooke.ca/") assert ev.is_free in (True, False) # booléen `payant` de la source assert any(e.image.startswith("https://www.sherbrooke.ca/Fichiers/") for e in events) def test_lavitrine_parses_fixture(): from sortika.connectors.lavitrine import LaVitrineConnector html = (FIXTURES / "lavitrine_fiche.html").read_text(encoding="utf-8") rows = LaVitrineConnector()._parse_fiche( "https://www.lavitrine.com/fr/evenement/100-ans/23777", html) assert rows and rows[0]["start_date"] == "2026-11-22" assert rows[0]["venue"] == "Théâtre Petit Champlain" assert rows[0]["city"] == "Québec" assert rows[0]["external_id"].startswith("23777-") # Phase 2 : l'heure de la slide (« 15:00 HNE ») est enfin captée assert rows[0]["start_time"] == "15:00" # Phase 2 : description = accroche courte + début du texte À propos assert rows[0]["description"].startswith( "DE CHARLES TRENET À STROMAE") assert len(rows[0]["description"]) >= 200 def test_lepointdevente_parses_fixture(): from sortika.connectors.lepointdevente import LePointDeVenteConnector html = (FIXTURES / "lepointdevente_page1.html").read_text(encoding="utf-8") events = [e.finalize() for e in LePointDeVenteConnector()._parse_page(html)] assert len(events) >= 40 ev = next(e for e in events if "JOAT" in e.title) assert ev.city == "Montréal" and ev.region == "Montréal" assert ev.venue == "Club Soda" assert ev.start_date == "2026-09-06" # vague 2 : la carte de liste porte l'heure (« …, 20h00 ») quand la source # la publie (JOAT est multi-jours sans heure : start_time None, jamais inventé) assert ev.start_time is None assert sum(1 for e in events if e.start_time) >= 35 assert all(len(e.start_time) == 5 for e in events if e.start_time) assert ev.url.startswith("https://lepointdevente.com/billets/") # dates FR parsées et régions rattachées hors Montréal aussi stq = next(e for e in events if e.city == "Saint-Agapit") assert stq.region == "Chaudière-Appalaches" # entités HTML décodées (pas de ' résiduel) assert not any("&#" in e.title for e in events) def test_laval_parses_fixture(monkeypatch): from sortika.connectors.laval import LavalConnector c = LavalConnector() _patch_json(monkeypatch, c, [json.loads( (FIXTURES / "laval_sample.json").read_text(encoding="utf-8"))]) events = [e.finalize() for e in c.fetch()] assert len(events) == 5 ev = events[0] assert ev.source == "laval" and ev.region == "Laval" assert ev.url.startswith("https://www.laval.ca/") assert ev.start_date and ev.start_date.startswith("20") assert "<" not in ev.description # HTML de l'Excerpt nettoyé def test_sitq_parses_fixture(monkeypatch): c = SitqConnector() _no_enrich(monkeypatch, c) # enrichissement des sites : pas de réseau _patch_json(monkeypatch, c, [json.loads( (FIXTURES / "sitq_sample.json").read_text(encoding="utf-8"))]) events = [e.finalize() for e in c.fetch()] # Phase 3 : les périodes multiples (PeriodeOuvertures[]) sont déroulées en # occurrences datées — la fixture porte 4 fiches dont une à 2 périodes assert len(events) == 5 suffixed = [e for e in events if e.external_id.endswith("-p1")] assert len(suffixed) == 1 base = next(e for e in events if e.external_id == suffixed[0].external_id[:-3]) assert base.title == suffixed[0].title assert base.start_date != suffixed[0].start_date jack = next(e for e in events if e.title == "JACKALOPE") assert jack.uid == "sitq:EVENMTOV500NIO" assert jack.city == "Montréal" assert jack.region == "Montréal" assert jack.start_date == "2026-09-11" and jack.end_date == "2026-09-13" assert jack.lat and 45 < jack.lat < 46 assert jack.website.startswith("http") assert "musique" in jack.categories or "sport" in jack.categories def test_montreal_parses_fixture(monkeypatch): c = MontrealConnector() _no_enrich(monkeypatch, c) # enrichissement des fiches : pas de réseau fixture = json.loads((FIXTURES / "montreal_sample.json").read_text(encoding="utf-8")) # une seule page : total = nb d'enregistrements de la fixture fixture["result"]["total"] = len(fixture["result"]["records"]) _patch_json(monkeypatch, c, [fixture]) events = [e.finalize() for e in c.fetch()] assert len(events) == 4 ev = events[0] assert ev.source == "montreal" assert ev.city == "Montréal" and ev.region == "Montréal" assert ev.start_date and ev.start_date.startswith("20") assert ev.url.startswith("https://montreal.ca/") # « Gratuit » de la fixture → is_free True, jamais un prix inventé frees = [e for e in events if e.is_free] assert frees and all(e.price_min is None for e in frees) def test_sherbrooke_times_converted_to_local(monkeypatch): """Les périodes UTC deviennent date + heure locales (placeholder = None).""" from sortika.connectors.sherbrooke import SherbrookeConnector c = SherbrookeConnector() html = (FIXTURES / "sherbrooke_page.html").read_text(encoding="utf-8") monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})()) events = [e.finalize() for e in c.fetch()] # fixture : 11:30:00Z (été) → 07:30 locale ; 00:00:00Z → 20:00 locale times = {e.start_time for e in events} assert "07:30" in times or "20:00" in times def test_ticketmaster_without_key_scrapes_discover(monkeypatch): """Sans clé, le connecteur scrape les pages découverte (état cityEvents) : hit → Event avec date UTC convertie en locale, salle géolocalisée.""" from sortika.connectors import ticketmaster as tm monkeypatch.delenv("TICKETMASTER_API_KEY", raising=False) hit = { "id": "310064A20EB06FDE", "title": "Mardi Latin Groove", "url": "https://www.ticketmaster.ca/x/event/310064A20EB06FDE", "dates": {"dateDisplay": "showDateTime", "startDate": "2026-08-18T23:00:00Z"}, "seatmapUrl": "https://mapsapi.tmol.io/maps/x.png", "venue": {"name": "Le Balcon X Terrasse", "city": "Montreal", "state": "QC", "latitude": 45.50543786, "longitude": -73.56881164, "addressLineOne": "463 Rue Sainte-Catherine Ouest", "code": "H3B1B1"}, "majorCategory": {"id": "KZFzniwnSyZfZ7v7nJ"}, } c = tm.TicketmasterConnector() monkeypatch.setattr(c, "_city_page", lambda city, page: ([hit], 1) if page == 0 else ([], 1)) events = [e.finalize() for e in c.fetch()] # 1 seul événement malgré les 3 villes crawlées (dédoublonnage par id) assert len(events) == 1 ev = events[0] assert ev.external_id == "310064A20EB06FDE" assert ev.start_date == "2026-08-18" and ev.start_time == "19:00" # UTC→locale # Phase 2 : « Montreal » (graphie source) → nom canonique MAMH assert ev.venue == "Le Balcon X Terrasse" and ev.city == "Montréal" assert ev.lat == 45.505438 and ev.region == "Montréal" assert ev.categories # Concerts → taxonomie canonique assert ev.is_free is None and ev.price_min is None # jamais inventé def test_ticketmaster_scrape_keeps_cancelled_with_status_and_tba_time(): """Phase 2 : un événement annulé est CONSERVÉ avec status=cancelled (bandeau frontend + eventStatus schema.org), plus jeté.""" from sortika.connectors.ticketmaster import TicketmasterConnector c = TicketmasterConnector() ev = c._scrape_hit_to_event({"id": "x", "title": "T", "cancelled": True}) assert ev is not None and ev.status == "cancelled" ev = c._scrape_hit_to_event({ "id": "y", "title": "Sans heure annoncée", "dates": {"dateDisplay": "showDateOnly", "startDate": "2026-09-01T04:00:00Z"}, "venue": {}}) assert ev is not None and ev.start_time is None and ev.status == "" def test_ticketmaster_parses_hit(): """Parse d'un hit Discovery v2 (structure officielle documentée).""" from sortika.connectors.ticketmaster import TicketmasterConnector hit = { "id": "G5vYZ9x1", "name": "Les Cowboys Fringants", "url": "https://www.ticketmaster.ca/x/G5vYZ9x1", "dates": {"start": {"localDate": "2026-09-20", "localTime": "19:30:00"}}, "classifications": [{"segment": {"name": "Music"}, "genre": {"name": "Rock"}}], "priceRanges": [{"min": 45.0, "max": 120.0}], "images": [{"ratio": "16_9", "width": 1024, "url": "https://img/x.jpg"}, {"ratio": "4_3", "width": 305, "url": "https://img/s.jpg"}], "_embedded": { "venues": [{"name": "Centre Bell", "city": {"name": "Montréal"}, "postalCode": "H4B 5G0", "address": {"line1": "1909 Avenue des Canadiens"}, "location": {"latitude": "45.496", "longitude": "-73.569"}}], "attractions": [{"name": "Les Cowboys Fringants"}]}, } ev = TicketmasterConnector()._hit_to_event(hit).finalize() assert ev.uid == "ticketmaster:G5vYZ9x1" assert ev.start_date == "2026-09-20" and ev.start_time == "19:30" assert ev.venue == "Centre Bell" and ev.city == "Montréal" assert ev.lat and 45 < ev.lat < 46 assert ev.price_min == 45.0 and "120" in ev.price_label assert ev.artists == ["Les Cowboys Fringants"] assert ev.image == "https://img/x.jpg" assert "musique" in ev.categories def test_bandsintown_filters_quebec(monkeypatch): """Seules les dates au Québec sont gardées (structure API réelle 2026-08).""" from sortika.connectors.bandsintown import BandsintownConnector c = BandsintownConnector() payload = [ {"id": "1038531745", "url": "https://www.bandsintown.com/e/1038531745", "datetime": "2026-08-18T18:45:00", "title": "", "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"}, "venue": {"name": "RBC Amphitheatre", "city": "Toronto", "country": "Canada", "region": "ON", "latitude": "43.62", "longitude": "-79.41"}, "lineup": ["Deep Purple"], "offers": [], "free": False}, {"id": "1038531746", "url": "https://www.bandsintown.com/e/1038531746", "datetime": "2026-09-02T20:00:00", "title": "", "artist": {"name": "Deep Purple", "image_url": "https://img/dp.jpg"}, "venue": {"name": "Centre Vidéotron", "city": "Québec", "country": "Canada", "region": "QC", "latitude": "46.828", "longitude": "-71.247"}, "lineup": ["Deep Purple"], "offers": [], "free": False}, ] monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload) rows = c._artist_events("Deep Purple") assert len(rows) == 1 r = rows[0] assert r["city"] == "Québec" and r["venue"] == "Centre Vidéotron" assert r["date"] == "2026-09-02" and r["time"] == "20:00" assert r["lineup"] == ["Deep Purple"] def test_atuvu_boilerplate_description_not_published(): """La og:description gabarit (« trouvez des billets à tarif réduit… ») n'est jamais publiée comme description d'événement.""" from sortika.connectors.atuvu import AtuvuConnector html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8") rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html) assert rows and rows[0]["description"] == "" def test_laval_cancelled_prefix_becomes_status(monkeypatch): """« ANNULÉ - Titre » → status=cancelled, titre nettoyé (Phase 2).""" from sortika.connectors.laval import LavalConnector c = LavalConnector() rec = {"Title": "ANNULÉ - Rencontre avec Kevin Raphaël", "PageUrl": "/activites/rencontre.aspx", "EventDate": "2026-10-01T22:30:00", "EndDate": "2026-10-01T23:30:00"} monkeypatch.setattr(c, "get_json", lambda *a, **kw: [rec]) events = [e.finalize() for e in c.fetch()] assert len(events) == 1 assert events[0].status == "cancelled" assert events[0].title == "Rencontre avec Kevin Raphaël" def test_eventbrite_result_to_event_end_time_status_organizer(): """Champs Phase 2 du payload __SERVER_DATA__ réel : end_time, statut (is_cancelled conservé), full_description, organisateur du JSON-LD.""" from sortika.connectors.eventbrite import EventbriteConnector r = {"id": "123", "name": "Atelier de poterie", "url": "https://www.eventbrite.ca/e/atelier-123", "summary": "court", "full_description": "Une description longue " * 20, "start_date": "2026-09-12", "start_time": "19:00", "end_date": "2026-09-12", "end_time": "21:30", "is_cancelled": True, "urgency_signals": {"messages": [], "categories": []}, "primary_venue": {"name": "Studio X", "address": {"region": "QC", "city": "Montréal", "address_1": "1 rue Test", "postal_code": "H2X 1Y6", "latitude": 45.5, "longitude": -73.6}}} ev = EventbriteConnector()._result_to_event( r, {"price": None, "organizer": "Les Ateliers MTL", "description": ""}).finalize() assert ev.end_time == "21:30" assert ev.status == "cancelled" # conservé, plus jeté assert ev.organizer == "Les Ateliers MTL" assert len(ev.description) >= 200 # full_description > summary # summary court + description JSON-LD de la fiche → la plus riche gagne r2 = dict(r, full_description="") ev2 = EventbriteConnector()._result_to_event( r2, {"price": None, "organizer": "", "description": "Description détaillée de la fiche. " * 10}) assert len(ev2.description) >= 200 def test_eventbrite_parse_detail_offers_and_organizer(): from sortika.connectors.eventbrite import _parse_detail html = ('') d = _parse_detail(html) assert d["organizer"] == "Prod QC" assert d["price"] == {"low": 25.0, "high": 45.0, "cur": "CAD"} assert d["description"] == "" # clé toujours présente def test_bandsintown_offers_ticket_url_and_soldout(monkeypatch): from sortika.connectors.bandsintown import BandsintownConnector c = BandsintownConnector() payload = [{"id": "9", "url": "https://www.bandsintown.com/e/9", "datetime": "2026-09-02T20:00:00", "title": "", "artist": {"name": "X"}, "lineup": ["X"], "offers": [{"type": "Tickets", "status": "sold out", "url": "https://billets.example/x"}], "venue": {"name": "Salle Y", "city": "Québec", "country": "Canada", "region": "QC"}}] monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload) rows = c._artist_events("X") assert rows[0]["url"] == "https://billets.example/x" assert rows[0]["offer_status"] == "sold out" def test_longueuil_image_from_nuxt_state(): from sortika.connectors.longueuil import _IMG_RES, _js_str state = ('x:{bg:"https:\\u002F\\u002Fcms.longueuil.quebec\\u002Fsites' '\\u002Fdefault\\u002Ffiles\\u002Fstyles\\u002Ffront_medium_16_9' '\\u002Fpublic\\u002Fmedias\\u002Fimages\\u002F2022-03' '\\u002Fcvl231121-04h_0.jpg?itok=GYMG8rcf"}') m = _IMG_RES[0].search(state) assert m url = _js_str(m.group(1)) assert url.startswith("https://cms.longueuil.quebec/sites/") assert "front_medium_16_9" in url and url.endswith("itok=GYMG8rcf") def test_brossard_parses_fiche_banner_and_jsonld(): from sortika.connectors.brossard import BrossardConnector c = BrossardConnector() # bannière des fiches ville (structure réelle brossard.ca, 2026-08) ville = ('
Cet événement aura lieu ' 'le 6 décembre 2026, de 15 h 00 à 16 h 00
' '') info = c._parse_fiche(ville) assert info["start_date"] == "2026-12-06" and info["time"] == "15:00" assert info["end_time"] == "16:00" # Phase 2 : « de 15 h 00 à 16 h 00 » assert info["image"] == "https://brossard.ca/i.jpg" # JSON-LD Event des fiches biblio.brossard.ca (structure réelle, 2026-08) biblio = ('') info = c._parse_fiche(biblio) assert info["start_date"] == "2026-11-27" and info["time"] == "13:30" assert info["end_date"] == "2026-11-27" assert info["end_time"] == "15:00" # Phase 2 : endDate JSON-LD assert info["venue"] == "Salle d'animation" assert info["postal_code"] == "J4X 2A4" # -- Vague « données ouvertes / agrégateurs » 2026-08-25 ---------------------- def test_atuvu_multiseances_same_day_suffixed(): """2 représentations le même jour = 2 événements distincts : la 1ʳᵉ garde l'id historique (zéro churn), la 2ᵉ est suffixée par son heure — avant, les external_id identiques s'écrasaient à l'ingestion (fiche réelle We call it Ballet, 3 dates × 2 séances, figée 2026-08-25).""" from sortika.connectors.atuvu import AtuvuConnector html = (FIXTURES / "atuvu_fiche_multiseances.html").read_text(encoding="utf-8") rows = AtuvuConnector()._parse_fiche( "https://atuvu.ca/billets-spectacle/" "we-call-it-ballet-la-belle-au-bois-dormant-danse-et-spectacle-de-lumiere", html) assert len(rows) == 6 ids = [r["external_id"] for r in rows] assert len(set(ids)) == 6 # plus aucun doublon d'id sep12 = [r for r in rows if r["start_date"] == "2026-09-12"] assert sep12[0]["external_id"].endswith("-2026-09-12") # id historique assert sep12[1]["external_id"].endswith("-2026-09-12-1830") assert sep12[0]["start_time"] == "16:00" assert sep12[1]["start_time"] == "18:30" assert all(r["venue"] == "Théâtre Rialto" for r in rows) def test_atuvu_fixture_time_and_price_still_parsed(): """La fiche arkansas (heure « - 20h00 » et prix .price-reg) reste bien parsée par le parseur v2 — c'est le backfill de ces champs que la version « v » du cache déclenche.""" from sortika.connectors.atuvu import AtuvuConnector html = (FIXTURES / "atuvu_fiche.html").read_text(encoding="utf-8") rows = AtuvuConnector()._parse_fiche("https://atuvu.ca/billets-spectacle/arkansas", html) assert rows[0]["start_time"] == "20:00" assert rows[0]["price_min"] == 27.0 def test_quebecanimee_fiche_lieu_adresse_heures(): """La fiche publie le lieu réel (.lieu-nom), l'adresse civique (.lieu-adresse) et l'heure par représentation (data-date/data-heure) — fiche réelle figée 2026-08-25. L'ancien motif générique attrapait le menu « Lieux » du site (venue 100 % bruit).""" from sortika.connectors.quebecanimee import QuebecAnimeeConnector html = (FIXTURES / "quebecanimee_fiche.html").read_text(encoding="utf-8") det = QuebecAnimeeConnector()._parse_fiche(html) assert det["venue"] == "Maison Tessier-Dit-Laplante" assert det["address"] == "2328, avenue Royale" assert det["times"] == {"2026-08-30": "13:30"} assert det["v"] >= 2 assert det["venue"] != "Lieux" def test_lepointdevente_detail_address_microdata(): """La fiche /billets/ publie l'adresse civique du lieu en microdonnées schema.org (streetAddress + addressLocality) — fiche réelle figée 2026-08-25 → géocodage aval possible (GPS était à 30 %).""" from sortika.connectors.lepointdevente import LePointDeVenteConnector html = (FIXTURES / "lepointdevente_fiche.html").read_text(encoding="utf-8") info = LePointDeVenteConnector()._parse_detail(html) assert info["address"] == "6 rue de l'Église" assert info["city"] == "Sainte-Thérèse" def test_bandsintown_image_recovered_from_sibling_event(monkeypatch): """L'objet `artist` est un dict VIDE sur certains événements (constaté 2026-08-25 sur les dates QC — cause de l'image à 8 %) : l'image est reprise d'un autre événement de la même réponse.""" from sortika.connectors.bandsintown import BandsintownConnector c = BandsintownConnector() payload = [ {"id": "1", "datetime": "2026-09-25T19:30:00", "artist": {"name": "PW", "image_url": "https://photos/pw.jpeg"}, "venue": {"name": "RBG", "city": "Burlington", "country": "Canada", "region": "ON"}}, {"id": "2", "datetime": "2026-10-02T20:00:00", "artist": {}, "venue": {"name": "MTELUS", "city": "Montréal", "country": "Canada", "region": "QC"}}, ] monkeypatch.setattr(c, "get_json", lambda *a, **kw: payload) rows = c._artist_events("PW") assert len(rows) == 1 and rows[0]["venue"] == "MTELUS" assert rows[0]["image"] == "https://photos/pw.jpeg" def test_bandsintown_image_profile_fallback(monkeypatch): """Aucune image dans toute la réponse mais des dates QC : 1 GET de repli sur le profil /artists/{artiste} (image_url canonique).""" from sortika.connectors.bandsintown import BandsintownConnector c = BandsintownConnector() events = [{"id": "2", "datetime": "2026-10-02T20:00:00", "artist": {}, "venue": {"name": "MTELUS", "city": "Montréal", "country": "Canada", "region": "QC"}}] profile = {"name": "Down", "image_url": "https://photos/down.jpeg"} _patch_json(monkeypatch, c, [events, profile]) rows = c._artist_events("Down") assert rows[0]["image"] == "https://photos/down.jpeg" def test_sitq_site_og_image_and_description(): """Le site officiel d'un événement SITQ livre og:image ET og:description (page réelle festivalmigrateurs.com figée 2026-08-25) — le flux Tourinsoft ne publie ni photo ni texte descriptif.""" from sortika.connectors.sitq import _parse_site html = (FIXTURES / "sitq_site.html").read_text(encoding="utf-8") info = _parse_site(html) assert info["image"].startswith("https://festivalmigrateurs.com/") assert info["desc"].startswith("Cet automne, levez les yeux vers le ciel") assert info["v"] >= 2 def test_sitq_site_short_slogan_not_a_description(): """Un og:description trop court (slogan / nom de site) n'est jamais publié comme description.""" from sortika.connectors.sitq import _parse_site info = _parse_site('' '') assert info["image"] == "https://x.com/i.jpg" assert info["desc"] == "" def test_lavitrine_representation_jsonld_address_gps_price(): """Les pages « représentation » du sitemap (…//) publient un JSON-LD Event complet : salle, adresse civique, code postal, GPS et prix billetterie — attachés à la représentation COURANTE (jamais listée dans son propre carrousel), date+heure LOCALES du fil d'Ariane (le startDate JSON-LD est en UTC : 17 sept 20:00 HAE → 18 sept 00:00 Z). Page réelle figée 2026-08-25.""" from sortika.connectors.lavitrine import LaVitrineConnector html = (FIXTURES / "lavitrine_fiche_representation.html").read_text(encoding="utf-8") rows = LaVitrineConnector()._parse_fiche( "https://www.lavitrine.com/fr/evenement/" "jf-pauze-les-amours-de-seconde-main/22040/1559973", html) assert len(rows) == 25 # 24 slides de tournée + la rep courante rep = [r for r in rows if r.get("address")] assert len(rep) == 1 r = rep[0] assert r["external_id"] == "22040-2026-09-17" # même schéma que les slides assert (r["start_date"], r["start_time"]) == ("2026-09-17", "20:00") assert r["venue"] == "Salle Odyssée" assert r["address"] == "855 Bd de la Gappe" assert (r["city"], r["postal_code"]) == ("Gatineau", "J8T 8H9") assert (r["lat"], r["lng"]) == (45.4845871, -75.6819682) assert r["price_min"] == 51.0 assert "billetterie lavitrine.com" in r["price_label"] # le slug lisible (pas l'id numérique « 22040 ») sert de catégorie brute assert all("22040" not in c for c in r["raw_categories"]) def test_lavitrine_fiche_fixture_still_parsed(): """Non-régression v3 : la fiche classique (sans JSON-LD) donne toujours ses slides datées avec heure et salle, sans adresse inventée.""" from sortika.connectors.lavitrine import LaVitrineConnector html = (FIXTURES / "lavitrine_fiche.html").read_text(encoding="utf-8") rows = LaVitrineConnector()._parse_fiche( "https://www.lavitrine.com/fr/evenement/100-ans/23777", html) assert rows and rows[0]["external_id"] == "23777-2026-11-22" assert rows[0]["start_time"] == "15:00" assert rows[0]["venue"] == "Théâtre Petit Champlain" assert not rows[0].get("address") assert rows[0].get("price_min") is None