# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # tests/test_mortgage_store.py : historisation par périodes de validité — # inchangé = simple last_checked ; changé = fermeture + nouvelle ligne ; # saut aberrant rejeté sans écraser la bonne donnée. # ----------------------------------------------------------------------------- import tempfile import time import unittest from pathlib import Path from immoka.mortgage import store def product(**over) -> dict: base = { "provider": "test", "institution": "Banque Test", "rate_type": "fixed", "term_months": 60, "kind": "special", "rate": 4.84, "apr": 4.90, "insured_status": "unknown", "purpose": "purchase", "product_name": "Fixe fermé 5 ans (spécial)", "source_url": "https://example.com/taux", "raw": {"src": "test"}, } base.update(over) return base class StoreTest(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self._old_path = store.DB_PATH self._old_ready = store._SCHEMA_READY store.DB_PATH = Path(self._tmp.name) / "mortgage.db" store._SCHEMA_READY = False self.con = store.connect() def tearDown(self): self.con.close() store.DB_PATH = self._old_path store._SCHEMA_READY = self._old_ready self._tmp.cleanup() def _count(self, where: str = "1=1") -> int: return self.con.execute( f"SELECT COUNT(*) AS n FROM rate_observations WHERE {where}" ).fetchone()["n"] def test_unchanged_updates_last_checked_only(self): store.record_observations(self.con, "test", [product()]) first = self.con.execute( "SELECT last_checked FROM rate_observations").fetchone()[0] time.sleep(0.02) res = store.record_observations(self.con, "test", [product()]) self.assertEqual(res["changed"], 0) self.assertEqual(self._count(), 1) # aucune nouvelle ligne second = self.con.execute( "SELECT last_checked FROM rate_observations").fetchone()[0] self.assertGreater(second, first) def test_change_closes_and_inserts(self): store.record_observations(self.con, "test", [product(rate=4.84)]) res = store.record_observations(self.con, "test", [product(rate=4.69)]) self.assertEqual(res["changed"], 1) self.assertEqual(self._count(), 2) self.assertEqual(self._count("valid_to IS NULL"), 1) current = self.con.execute( "SELECT rate FROM rate_observations WHERE valid_to IS NULL" ).fetchone()["rate"] self.assertEqual(current, 4.69) closed = self.con.execute( "SELECT rate, valid_to FROM rate_observations " "WHERE valid_to IS NOT NULL").fetchone() self.assertEqual(closed["rate"], 4.84) # historique intact def test_aberrant_jump_rejected(self): store.record_observations(self.con, "test", [product(rate=4.19)]) res = store.record_observations(self.con, "test", [product(rate=8.50)]) self.assertEqual(res["rejected"], 1) self.assertEqual(self._count(), 1) # la bonne donnée n'est pas écrasée current = self.con.execute( "SELECT rate FROM rate_observations WHERE valid_to IS NULL" ).fetchone()["rate"] self.assertEqual(current, 4.19) def test_small_change_accepted(self): store.record_observations(self.con, "test", [product(rate=4.19)]) res = store.record_observations(self.con, "test", [product(rate=4.44)]) self.assertEqual(res["rejected"], 0) self.assertEqual(res["changed"], 1) def test_product_key_separates_incomparables(self): keys = {store.product_key(product(**over)) for over in ( {}, {"kind": "posted"}, {"insured_status": "insured"}, {"term_months": 36}, {"rate_type": "variable"}, {"product_name": "Autre produit"}, )} self.assertEqual(len(keys), 6) # jamais comparer l'incomparable def test_best_rate_special_beats_posted(self): store.record_observations(self.con, "a", [ product(provider="a", kind="posted", rate=6.09, product_name="Fixe affiché"), product(provider="a", kind="special", rate=4.84, product_name="Fixe spécial"), ]) store.record_observations(self.con, "b", [ product(provider="b", institution="Banque B", kind="special", rate=4.99, product_name="Fixe spécial B"), ]) best = store.best_rate(self.con, "fixed", 60) self.assertEqual(best["provider"], "a") self.assertEqual(best["rate"], 4.84) self.assertEqual(best["kind"], "special") self.assertEqual(best["institutions_count"], 2) # le posted 6,09 de « a » ne doit pas être candidat self.assertEqual([r["rate"] for r in best["per_institution"]], [4.84, 4.99]) def test_best_rate_none_when_empty(self): self.assertIsNone(store.best_rate(self.con, "fixed", 60)) def test_rate_at_now(self): store.record_observations(self.con, "test", [product(rate=4.84)]) self.assertEqual(store.rate_at(self.con, "fixed", 60, time.time()), 4.84) def test_history_returns_periods(self): store.record_observations(self.con, "test", [product(rate=4.84)]) store.record_observations(self.con, "test", [product(rate=4.69)]) rows = store.history(self.con, "fixed", 60) self.assertEqual(len(rows), 2) self.assertIsNotNone(rows[0]["valid_to"]) self.assertIsNone(rows[1]["valid_to"]) def test_provider_health_levels(self): store.record_observations(self.con, "test", [product()]) store.log_run(self.con, "test", ok=True, status="success", products=1) store.log_run(self.con, "vide", ok=False, status="http_error") health = {h["provider"]: h for h in store.provider_health(self.con)} self.assertEqual(health["test"]["level"], "OK") self.assertEqual(health["vide"]["level"], "ERROR") if __name__ == "__main__": unittest.main()