Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: tests/test_schema_db.py4# Desc: Validation du schéma (menu sans price_context REJETÉ), cycle de vie5# en base (upsert, délai de grâce, historique des prix, garde-fou de6# chute d'items) et déduplication inter-sources.7# ==============================================================================8import json910import pytest1112from restoka import db, dedup13from restoka.schema import MenuValidationError, Restaurant141516def _resto(source="ueat", eid="g:1", name="Chez Test", menu=True, price=12.0,17 **kw) -> Restaurant:18 m = None19 if menu:20 m = {21 "currency": "CAD",22 "price_context": "takeout",23 "price_source": source,24 "captured_at": "2026-08-17T12:00:00Z",25 "sections": [{"name": "Plats", "items": [26 {"name": "Poutine", "price": price},27 {"name": "Burger", "price": 14.5},28 ]}],29 }30 defaults = dict(source=source, external_id=eid, name=name,31 address="123 Rue Principale", city="Québec",32 postal_code="G1R 4P5", menu=m)33 defaults.update(kw)34 return Restaurant(**defaults)353637class TestValidation:38 def test_menu_sans_contexte_rejete(self):39 r = _resto()40 r.menu.pop("price_context")41 with pytest.raises(MenuValidationError):42 r.finalize()4344 def test_menu_sans_source_rejete(self):45 r = _resto()46 r.menu.pop("price_source")47 with pytest.raises(MenuValidationError):48 r.finalize()4950 def test_contexte_invalide_rejete(self):51 r = _resto()52 r.menu["price_context"] = "menu"53 with pytest.raises(MenuValidationError):54 r.finalize()5556 def test_prix_implausible_rejete(self):57 with pytest.raises(MenuValidationError):58 _resto(price=99999).finalize()5960 def test_finalize_enrichit(self):61 r = _resto().finalize()62 assert r.region == "Capitale-Nationale"63 assert r.cuisines # jamais vide64 assert r.price_range == "$"65 assert r.uid == "ueat:g:1"6667 def test_coordonnees_hors_province_rejetees(self):68 r = _resto(lat=48.85, lng=2.35).finalize() # Paris -> aberrant69 assert r.lat is None and r.lng is None707172class TestSync:73 def test_ajout_puis_stable(self, con):74 stats = db.sync_source(con, "ueat", [_resto().finalize()])75 assert stats["added"] == 176 stats = db.sync_source(con, "ueat", [_resto().finalize()])77 assert stats["added"] == 0 and stats["updated"] == 07879 def test_changement_de_prix_historise(self, con):80 db.sync_source(con, "ueat", [_resto(price=12.0).finalize()])81 db.sync_source(con, "ueat", [_resto(price=13.5).finalize()])82 hist = con.execute(83 "SELECT price FROM item_price_log WHERE item_key LIKE '%poutine%'"84 " ORDER BY ts").fetchall()85 assert [h["price"] for h in hist] == [12.0, 13.5]8687 def test_delai_de_grace_puis_fermeture(self, con):88 db.sync_source(con, "ueat", [_resto().finalize()])89 db.sync_source(con, "ueat", []) # raté 1 : encore actif90 row = con.execute("SELECT active FROM restaurants").fetchone()91 assert row["active"] == 192 db.sync_source(con, "ueat", []) # raté 2 : fermé temporairement93 row = con.execute("SELECT active, status FROM restaurants").fetchone()94 assert row["active"] == 0 and row["status"] == "temporarily_closed"9596 def test_chute_items_conserve_ancien_menu(self, con):97 gros = _resto()98 gros.menu["sections"] = [{"name": "Plats", "items": [99 {"name": f"Plat {i}", "price": 10.0 + i} for i in range(12)]}]100 db.sync_source(con, "ueat", [gros.finalize()])101 petit = _resto(price=9.0)102 petit.menu["sections"] = [{"name": "Plats",103 "items": [{"name": "Seul", "price": 9.0}]}]104 stats = db.sync_source(con, "ueat", [petit.finalize()])105 assert stats.get("menu_alerts") == 1106 row = con.execute("SELECT item_count FROM menus").fetchone()107 assert row["item_count"] == 12 # l'ancien menu est conservé108109 def test_contextes_jamais_ecrases(self, con):110 salle = _resto()111 salle.menu["price_context"] = "dine-in"112 db.sync_source(con, "site-resto" if False else "ueat", [salle.finalize()])113 livraison = _resto()114 livraison.menu["price_context"] = "delivery"115 livraison.menu["sections"][0]["items"][0]["price"] = 16.0 # majoré116 db.sync_source(con, "ueat", [livraison.finalize()])117 rows = con.execute(118 "SELECT price_context FROM menus ORDER BY price_context").fetchall()119 assert [r["price_context"] for r in rows] == ["delivery", "dine-in"]120121122class TestDedup:123 def test_meme_resto_deux_sources(self, con):124 a = _resto(source="ueat", eid="1", lat=46.81, lng=-71.21).finalize()125 b = _resto(source="ubereats", eid="x9", lat=46.8101, lng=-71.2101,126 menu=False).finalize()127 db.sync_source(con, "ueat", [a])128 db.sync_source(con, "ubereats", [b])129 stats = dedup.run()130 assert stats["merged"] == 1131 canon = con.execute(132 "SELECT uid, dup_sources FROM restaurants WHERE dup_of IS NULL"133 " AND dup_sources IS NOT NULL").fetchone()134 # la source de première partie (ueat) gagne la fiche canonique135 assert canon["uid"] == "ueat:1"136 assert json.loads(canon["dup_sources"]) == ["ubereats"]137138 def test_decouverte_derriere_le_menu(self, con):139 """Une fiche OSM (découverte, sans menu) au même endroit qu'une140 succursale UEAT nommée « Marque — Succursale » doit s'effacer141 derrière la fiche qui porte le menu."""142 u = _resto(source="ueat", eid="g:9", name="Ashton — 1e avenue",143 lat=46.8123, lng=-71.2340).finalize()144 o = _resto(source="osm", eid="node/42", name="Ashton",145 lat=46.8123, lng=-71.2340, menu=False).finalize()146 db.sync_source(con, "ueat", [u])147 db.sync_source(con, "osm", [o])148 stats = dedup.run()149 assert stats["merged"] == 1150 row = con.execute("SELECT dup_of FROM restaurants WHERE uid='osm:node/42'").fetchone()151 assert row["dup_of"] == "ueat:g:9"152153 def test_succursales_jamais_fusionnees(self, con):154 s1 = _resto(eid="1", name="Poulet Rouge — Beauport",155 address="898 Boulevard Raymond", postal_code="G1C 5P4").finalize()156 s2 = _resto(eid="2", name="Poulet Rouge — Sainte-Foy",157 address="2600 Boulevard Laurier", postal_code="G1V 4T3").finalize()158 db.sync_source(con, "ueat", [s1, s2])159 stats = dedup.run()160 assert stats["merged"] == 0161