# ============================================================================== # Author: Simon-Pierre Boucher # File: tests/test_new_connectors.py # Desc: Tests des connecteurs billetteries & agrégateurs (vague 2026-08-25 : # showpass, signelaval, quoifaire, evenementsattractions, banq, zeffy) # sur fixtures réelles figées — aucun appel réseau (CLAUDE.md §18). # accesculture a été ÉCARTÉ (domaine recyclé en ferme SEO, contenu # événementiel figé en 2015 → dates fabriquées, connecteur retiré). # ============================================================================== from __future__ import annotations import json from pathlib import Path from sortika.connectors import CONNECTORS FIXTURES = Path(__file__).parent / "fixtures" def test_registry_autodiscovers_new_wave(): for sid in ("showpass", "signelaval", "quoifaire", "evenementsattractions", "banq", "zeffy"): assert sid in CONNECTORS, sid # écarté : source morte (fiches 2015 sur domaine squatté) assert "accesculture" not in CONNECTORS def test_showpass_parses_fixture_and_filters_quebec(): from sortika.connectors.showpass import ShowpassConnector c = ShowpassConnector() data = json.loads((FIXTURES / "showpass_sample.json") .read_text(encoding="utf-8")) events = [e for e in (c._to_event(r) for r in data["results"]) if e] # la fixture mélange 2 événements QC et 3 ON : seul le Québec passe assert len(events) == 2 ev = events[0].finalize() assert ev.source == "showpass" assert ev.city == "Longueuil" and ev.region == "Montérégie" # heure LOCALE du fuseau de l'événement (local_starts_on), pas l'UTC assert ev.start_date == "2026-09-05" and ev.start_time == "20:30" assert ev.url.startswith("https://www.showpass.com/") assert ev.lat and 45.0 < ev.lat < 46.0 # tarifs réels de la billetterie (ticket_types[].price) assert ev.is_free is False and ev.price_min == 80.0 assert "$" in ev.price_label assert ev.image.startswith("https://") def test_signelaval_parses_fixture(): from sortika.connectors.signelaval import SigneLavalConnector row = SigneLavalConnector()._parse_fiche( "https://signelaval.com/fr/evenements/6a54abb7872b4a7e4d825995/" "bricolage-de-chien-pour-les-5-ans-et-plus", (FIXTURES / "signelaval_fiche.html").read_text(encoding="utf-8")) assert row["external_id"] == "6a54abb7872b4a7e4d825995" assert row["title"] == "Bricolage de chien pour les 5 ans et plus" assert row["venue"] == "Bibliothèque Laure-Conan de Laval" assert row["city"] == "Laval" # dates ISO horodatées du JSON-LD Footlight (fuseau -04:00) assert row["start_date"] == "2026-08-25" and row["start_time"] == "18:30" assert row["end_time"] == "19:30" assert row["organizer"] == "Ville de Laval" assert row["image"].startswith("https://cdn.footlight.io/") assert row["description"].startswith("Pour célébrer la journée") def test_signelaval_parses_multilingual_lists(): # certaines fiches Footlight publient name/location/addressLocality en # LISTE multilingue (["Rythme latin", {"@language": "fr", ...}]) — # bug constaté à la validation live du 2026-08-25 : titre/ville sortaient # en repr Python et la région restait non résolue. from sortika.connectors.signelaval import SigneLavalConnector, _val row = SigneLavalConnector()._parse_fiche( "https://signelaval.com/fr/evenements/6a43d960872b4a7e4d732663/" "rythme-latin", (FIXTURES / "signelaval_fiche_multilingue.html") .read_text(encoding="utf-8")) assert row["title"] == "Rythme latin" assert row["venue"].startswith("Parc de l'Espoir") assert row["city"] == "Laval" assert row["start_date"] == "2026-08-04" and row["start_time"] == "18:30" # _val : littéral, {"@value"}, liste multilingue (fr prioritaire) assert _val("abc") == "abc" assert _val({"@value": "x"}) == "x" assert _val(["en text", {"@language": "fr", "@value": "texte fr"}]) \ == "texte fr" assert _val([{"@language": "en", "@value": "only en"}]) == "only en" from sortika.schema import Event ev = Event(source="signelaval", external_id=row["external_id"], url=row["url"], title=row["title"], city=row["city"]).finalize() assert ev.region == "Laval" def test_quoifaire_parses_liste_and_fiche(): from sortika.connectors.quoifaire import QuoiFaireConnector, _LINK_RE html = (FIXTURES / "quoifaire_liste.html").read_text(encoding="utf-8") links = set(_LINK_RE.findall(html)) assert len(links) >= 15 assert all("/evenements/" in u and "/categories/" not in u for u in links) row = QuoiFaireConnector()._parse_fiche( "https://quoifaire.com/quebec/evenements/coupe-davis", (FIXTURES / "quoifaire_fiche.html").read_text(encoding="utf-8")) assert row["title"] == "Coupe Davis" assert row["venue"] == "Centre Vidéotron" assert row["city"] == "Québec" # plage FR « 18 septembre 2026 - 19 septembre 2026 » assert row["start_date"] == "2026-09-18" and row["end_date"] == "2026-09-19" # « Heure : À confirmer » → jamais inventée assert row["start_time"] is None # prix publié conservé brut (parse_price en aval → price_min 40) assert row["price_label"] == "min. 40.00$ | max. 150.00$" assert float(row["lat"]) > 46 and float(row["lng"]) < -71 assert row["audience"] == "Tout public" assert "Sports et plein air" in row["raw_categories"] from sortika.schema import Event ev = Event(source="quoifaire", external_id="coupe-davis", url="https://quoifaire.com/quebec/evenements/coupe-davis", title=row["title"], tourist_region=row["tourist_region"], city=row["city"], price_label=row["price_label"]).finalize() assert ev.region == "Capitale-Nationale" assert ev.is_free is False and ev.price_min == 40.0 def test_evenementsattractions_parses_fixture(): from sortika.connectors.evenementsattractions import ( EvenementsAttractionsConnector) row = EvenementsAttractionsConnector()._parse_fiche( "https://www.quebecvacances.com/fr/evenements/francos-de-montreal", (FIXTURES / "evenementsattractions_fiche.html") .read_text(encoding="utf-8")) assert row["title"] == "Francos de Montréal" # « Montréal (ville de) » de la source → ville nue assert row["city"] == "Montréal" # dates de la prochaine édition, publiées par la source (jamais inventées) assert row["start_date"] == "2027-06-11" and row["end_date"] == "2027-06-19" assert row["address"].startswith("Quartier des spectacles") assert row["image"].startswith("https://res.cloudinary.com/") assert row["description"].startswith("Les Francos de Montréal") def test_evenementsattractions_repairs_mojibake(): # la chaîne anti-bot peut décoder l'HTML UTF-8 en latin-1 (constaté live # 2026-08-25 : « siège » au lieu de « siège ») → réparation déterministe from sortika.connectors.evenementsattractions import _utf8_text class Resp: text = "250e anniversaire du siège du Fort Saint-Jean Ã\xa0 venir" assert _utf8_text(Resp()) == "250e anniversaire du siège du Fort " \ "Saint-Jean à venir" class Clean: text = "Festival d'été de Québec — À l'affiche" assert _utf8_text(Clean()) == Clean.text # texte sain : inchangé def test_banq_parses_liste_and_fiche(): from sortika.connectors.banq import BanqConnector, _FICHE_RE liste = (FIXTURES / "banq_liste.html").read_text(encoding="utf-8") assert len(set(_FICHE_RE.findall(liste))) >= 100 row = BanqConnector()._parse_fiche( "https://www.banq.qc.ca/calendrier/a-venir/146831", (FIXTURES / "banq_fiche.html").read_text(encoding="utf-8")) assert row["title"] == "La boîte à jouets" assert row["venue"] == "Grande Bibliothèque" assert row["city"] == "Montréal" # dates ISO avec fuseau -0400 du JSON-LD assert row["start_date"] == "2026-08-27" assert row["start_time"] == "10:30" and row["end_time"] == "11:30" # gratuité PUBLIÉE par la source (offers.price = "0.00") assert row["is_free"] is True assert row["organizer"].startswith("Bibliothèque et Archives") def test_zeffy_parses_org_page_and_filters_quebec(): from sortika.connectors.zeffy import ZeffyConnector, _split_address rows = ZeffyConnector()._parse_org( "https://www.zeffy.com/fr-CA/organizations/iris-estrie", (FIXTURES / "zeffy_org_page.html").read_text(encoding="utf-8")) assert len(rows) == 1 row = rows[0] # id stable billetterie:occurrence (uuid Zeffy) assert row["external_id"] == ("51a2a141-a833-4aba-854f-8336c83d777c:" "2de1d983-bd23-4bb7-b82c-23aff08aae2f") assert row["title"] == "RESIST 2026" assert row["url"] == "https://www.zeffy.com/fr-CA/ticketing/resist--2026" assert row["city"] == "Sherbrooke" assert row["address"] == "69 Rue Wellington N" assert row["postal_code"] == "J1H 5A9" # occurrences startUtc/endUtc converties en heure de Montréal assert row["start_date"] == "2026-09-28" and row["start_time"] == "09:00" assert row["end_date"] == "2026-09-30" and row["end_time"] == "16:00" assert row["organizer"] == "I.R.I.S Estrie" assert row["image"].startswith("https://res.cloudinary.com/") from sortika.schema import Event ev = Event(source="zeffy", external_id=row["external_id"], url=row["url"], title=row["title"], city=row["city"]).finalize() assert ev.region == "Estrie" # filtre de périmètre : seules les adresses québécoises passent assert _split_address("123 Main St, Toronto, ON M5V 1A1, Canada") is None assert _split_address("") is None got = _split_address("1375 Ave.Thérèse-Lavoie-Roux, Montréal, " "QC H2V 0B3, Canada") assert got and got[1] == "Montréal" and got[2] == "H2V 0B3" def test_zeffy_parses_fiche_prices(): from sortika.connectors.zeffy import ZeffyConnector c = ZeffyConnector() prices = c._parse_fiche_prices( (FIXTURES / "zeffy_fiche.html").read_text(encoding="utf-8")) # tarifs PUBLIÉS par l'organisme (offers[].price) — tous à 0 $ → gratuit assert prices["is_free"] is True and prices["price_min"] is None assert "ITSS X HSH 0 $" in prices["price_label"] # tarifs payants synthétiques (structure JSON-LD réelle de Zeffy) html = ('') prices = c._parse_fiche_prices(html) assert prices["is_free"] is False and prices["price_min"] == 25.0 # aucune offre lisible → prix inconnu (jamais inventé) assert c._parse_fiche_prices("") is None