SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
6.2 KB · 145 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# tests/test_mortgage_store.py : historisation par périodes de validité —5#   inchangé = simple last_checked ; changé = fermeture + nouvelle ligne ;6#   saut aberrant rejeté sans écraser la bonne donnée.7# -----------------------------------------------------------------------------8import tempfile9import time10import unittest11from pathlib import Path1213from immoka.mortgage import store141516def product(**over) -> dict:17    base = {18        "provider": "test", "institution": "Banque Test",19        "rate_type": "fixed", "term_months": 60, "kind": "special",20        "rate": 4.84, "apr": 4.90, "insured_status": "unknown",21        "purpose": "purchase", "product_name": "Fixe fermé 5 ans (spécial)",22        "source_url": "https://example.com/taux", "raw": {"src": "test"},23    }24    base.update(over)25    return base262728class StoreTest(unittest.TestCase):29    def setUp(self):30        self._tmp = tempfile.TemporaryDirectory()31        self._old_path = store.DB_PATH32        self._old_ready = store._SCHEMA_READY33        store.DB_PATH = Path(self._tmp.name) / "mortgage.db"34        store._SCHEMA_READY = False35        self.con = store.connect()3637    def tearDown(self):38        self.con.close()39        store.DB_PATH = self._old_path40        store._SCHEMA_READY = self._old_ready41        self._tmp.cleanup()4243    def _count(self, where: str = "1=1") -> int:44        return self.con.execute(45            f"SELECT COUNT(*) AS n FROM rate_observations WHERE {where}"46        ).fetchone()["n"]4748    def test_unchanged_updates_last_checked_only(self):49        store.record_observations(self.con, "test", [product()])50        first = self.con.execute(51            "SELECT last_checked FROM rate_observations").fetchone()[0]52        time.sleep(0.02)53        res = store.record_observations(self.con, "test", [product()])54        self.assertEqual(res["changed"], 0)55        self.assertEqual(self._count(), 1)  # aucune nouvelle ligne56        second = self.con.execute(57            "SELECT last_checked FROM rate_observations").fetchone()[0]58        self.assertGreater(second, first)5960    def test_change_closes_and_inserts(self):61        store.record_observations(self.con, "test", [product(rate=4.84)])62        res = store.record_observations(self.con, "test", [product(rate=4.69)])63        self.assertEqual(res["changed"], 1)64        self.assertEqual(self._count(), 2)65        self.assertEqual(self._count("valid_to IS NULL"), 1)66        current = self.con.execute(67            "SELECT rate FROM rate_observations WHERE valid_to IS NULL"68        ).fetchone()["rate"]69        self.assertEqual(current, 4.69)70        closed = self.con.execute(71            "SELECT rate, valid_to FROM rate_observations "72            "WHERE valid_to IS NOT NULL").fetchone()73        self.assertEqual(closed["rate"], 4.84)  # historique intact7475    def test_aberrant_jump_rejected(self):76        store.record_observations(self.con, "test", [product(rate=4.19)])77        res = store.record_observations(self.con, "test", [product(rate=8.50)])78        self.assertEqual(res["rejected"], 1)79        self.assertEqual(self._count(), 1)  # la bonne donnée n'est pas écrasée80        current = self.con.execute(81            "SELECT rate FROM rate_observations WHERE valid_to IS NULL"82        ).fetchone()["rate"]83        self.assertEqual(current, 4.19)8485    def test_small_change_accepted(self):86        store.record_observations(self.con, "test", [product(rate=4.19)])87        res = store.record_observations(self.con, "test", [product(rate=4.44)])88        self.assertEqual(res["rejected"], 0)89        self.assertEqual(res["changed"], 1)9091    def test_product_key_separates_incomparables(self):92        keys = {store.product_key(product(**over)) for over in (93            {}, {"kind": "posted"}, {"insured_status": "insured"},94            {"term_months": 36}, {"rate_type": "variable"},95            {"product_name": "Autre produit"},96        )}97        self.assertEqual(len(keys), 6)  # jamais comparer l'incomparable9899    def test_best_rate_special_beats_posted(self):100        store.record_observations(self.con, "a", [101            product(provider="a", kind="posted", rate=6.09,102                    product_name="Fixe affiché"),103            product(provider="a", kind="special", rate=4.84,104                    product_name="Fixe spécial"),105        ])106        store.record_observations(self.con, "b", [107            product(provider="b", institution="Banque B", kind="special",108                    rate=4.99, product_name="Fixe spécial B"),109        ])110        best = store.best_rate(self.con, "fixed", 60)111        self.assertEqual(best["provider"], "a")112        self.assertEqual(best["rate"], 4.84)113        self.assertEqual(best["kind"], "special")114        self.assertEqual(best["institutions_count"], 2)115        # le posted 6,09 de « a » ne doit pas être candidat116        self.assertEqual([r["rate"] for r in best["per_institution"]],117                         [4.84, 4.99])118119    def test_best_rate_none_when_empty(self):120        self.assertIsNone(store.best_rate(self.con, "fixed", 60))121122    def test_rate_at_now(self):123        store.record_observations(self.con, "test", [product(rate=4.84)])124        self.assertEqual(store.rate_at(self.con, "fixed", 60, time.time()), 4.84)125126    def test_history_returns_periods(self):127        store.record_observations(self.con, "test", [product(rate=4.84)])128        store.record_observations(self.con, "test", [product(rate=4.69)])129        rows = store.history(self.con, "fixed", 60)130        self.assertEqual(len(rows), 2)131        self.assertIsNotNone(rows[0]["valid_to"])132        self.assertIsNone(rows[1]["valid_to"])133134    def test_provider_health_levels(self):135        store.record_observations(self.con, "test", [product()])136        store.log_run(self.con, "test", ok=True, status="success", products=1)137        store.log_run(self.con, "vide", ok=False, status="http_error")138        health = {h["provider"]: h for h in store.provider_health(self.con)}139        self.assertEqual(health["test"]["level"], "OK")140        self.assertEqual(health["vide"]["level"], "ERROR")141142143if __name__ == "__main__":144    unittest.main()145