snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 1s
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 1s
This commit is contained in:
193
crm/domain_enrichment/tests/test_pipeline.py
Normal file
193
crm/domain_enrichment/tests/test_pipeline.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
"""Pure, network-free pipeline tests.
|
||||
|
||||
The crawl is stubbed (``pipeline.crawl`` patched to return canned offline pages), so
|
||||
the whole pipeline runs with zero network. Asserts the assembled ``EnrichmentResult``,
|
||||
the explainable provenance schema (``to_dict``/``flat``) the mapper depends on, and
|
||||
the progress-step contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
from crm.domain_enrichment import pipeline as pipeline_mod
|
||||
from crm.domain_enrichment.pipeline import PROGRESS_STEPS, _normalize_website
|
||||
from crm.domain_enrichment.pipeline import run as run_pipeline
|
||||
from crm.domain_enrichment.result import EnrichmentResult
|
||||
from crm.domain_enrichment.tests import fixtures
|
||||
|
||||
# The schema the CRM field mapper relies on. If extractors change the shape these
|
||||
# assertions fail loudly instead of the mapper silently reading missing keys.
|
||||
EXPECTED_TOP_KEYS = {
|
||||
"company_name",
|
||||
"description",
|
||||
"logo",
|
||||
"image",
|
||||
"industry",
|
||||
"industry_confidence",
|
||||
"emails",
|
||||
"phones",
|
||||
"social_profiles",
|
||||
"_meta",
|
||||
}
|
||||
EXPECTED_FLAT_KEYS = {
|
||||
"company_name",
|
||||
"description",
|
||||
"logo",
|
||||
"industry",
|
||||
"industry_confidence",
|
||||
"social_profiles",
|
||||
}
|
||||
SCALAR_SUBKEYS = {"value", "source", "method"}
|
||||
|
||||
|
||||
def _full_config():
|
||||
"""An in-memory config wired with all the rule types the fixtures exercise."""
|
||||
return fixtures.make_config(
|
||||
settings={"max_pages": 5, "max_depth": 1, "request_timeout": 5},
|
||||
rules_by_type={
|
||||
"Industry": fixtures.industry_rules(),
|
||||
"Social": fixtures.social_rules(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _canned_crawl(*_args, **_kwargs):
|
||||
"""Return canned (CrawledPage, soup) tuples instead of crawling the network."""
|
||||
return [
|
||||
fixtures.make_page("https://acme.example", fixtures.HOMEPAGE),
|
||||
fixtures.make_page("https://acme.example/about", fixtures.ABOUT),
|
||||
fixtures.make_page("https://acme.example/contact", fixtures.CONTACT),
|
||||
]
|
||||
|
||||
|
||||
class NormalizeWebsiteTest(UnitTestCase):
|
||||
def test_adds_https_scheme(self):
|
||||
self.assertEqual(_normalize_website("frappe.io"), "https://frappe.io")
|
||||
|
||||
def test_rejects_empty(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_normalize_website("")
|
||||
|
||||
def test_rejects_bad_scheme(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_normalize_website("ftp://x.com")
|
||||
|
||||
|
||||
class PipelineRunTest(UnitTestCase):
|
||||
def setUp(self):
|
||||
self.cfg = _full_config()
|
||||
|
||||
def test_full_result_is_explainable(self):
|
||||
steps = []
|
||||
with mock.patch.object(pipeline_mod, "crawl", _canned_crawl):
|
||||
result = run_pipeline(
|
||||
"https://acme.example",
|
||||
cfg=self.cfg,
|
||||
progress=lambda i, m: steps.append((i, m)),
|
||||
)
|
||||
out = result.to_dict()
|
||||
|
||||
self.assertEqual(out["company_name"]["value"], "Acme Analytics Inc.")
|
||||
self.assertTrue(out["company_name"]["source"])
|
||||
self.assertTrue(out["company_name"]["method"])
|
||||
|
||||
# Every list item carries provenance.
|
||||
for item in out["emails"] + out["phones"]:
|
||||
self.assertTrue(item["source"], f"missing source: {item}")
|
||||
self.assertTrue(item["method"], f"missing method: {item}")
|
||||
|
||||
# Progress emitted, ending at the final step.
|
||||
emitted = [i for i, _ in steps]
|
||||
self.assertEqual(emitted[-1], len(PROGRESS_STEPS) - 1)
|
||||
|
||||
def test_flat_view(self):
|
||||
with mock.patch.object(pipeline_mod, "crawl", _canned_crawl):
|
||||
result = run_pipeline("https://acme.example", cfg=self.cfg)
|
||||
flat = result.flat()
|
||||
self.assertEqual(flat["company_name"], "Acme Analytics Inc.")
|
||||
self.assertIsInstance(flat["social_profiles"], dict)
|
||||
|
||||
def test_unreachable_site_returns_empty_without_raising(self):
|
||||
from crm.domain_enrichment.result import CrawledPage
|
||||
|
||||
def broken_crawl(*_a, **_k):
|
||||
return [(CrawledPage(url="https://x.example", status_code=0, error="timeout after 5s"), None)]
|
||||
|
||||
with mock.patch.object(pipeline_mod, "crawl", broken_crawl):
|
||||
result = run_pipeline("https://x.example", cfg=self.cfg)
|
||||
out = result.to_dict()
|
||||
self.assertEqual(out["company_name"]["value"], "")
|
||||
self.assertTrue(out["_meta"]["errors"])
|
||||
self.assertTrue(result.notes) # carries the "couldn't reach" note
|
||||
|
||||
|
||||
class JsSpaSiteTest(UnitTestCase):
|
||||
"""Enriching a JavaScript-only SPA (empty body, client-rendered content).
|
||||
|
||||
Without a render step the engine sees only the build-tool-default `<head>` metadata
|
||||
and an empty `<body>`. It must (a) flag the page as JS-rendered, (b) not fabricate
|
||||
a description/industry/contacts, and (c) still surface whatever metadata exists
|
||||
(title-tag company name + favicon), all without raising.
|
||||
"""
|
||||
|
||||
def _spa_crawl(self, *_a, **_k):
|
||||
return [fixtures.make_page("https://spa.example", fixtures.JS_SPA_SHELL)]
|
||||
|
||||
def test_js_only_site_yields_metadata_only(self):
|
||||
cfg = _full_config()
|
||||
with (
|
||||
mock.patch.object(pipeline_mod, "crawl", self._spa_crawl),
|
||||
# No About page is crawled; the probe must not hit the real network.
|
||||
mock.patch.object(pipeline_mod, "probe_about_pages", return_value=[]),
|
||||
):
|
||||
result = run_pipeline("https://spa.example", cfg=cfg)
|
||||
out = result.to_dict()
|
||||
|
||||
# Flagged as JS-rendered, and nothing fabricated.
|
||||
self.assertTrue(any("JavaScript-rendered" in n for n in result.notes))
|
||||
self.assertEqual(out["description"]["value"], "")
|
||||
self.assertEqual(out["industry"]["value"], "")
|
||||
self.assertEqual(out["emails"], [])
|
||||
self.assertEqual(out["phones"], [])
|
||||
self.assertTrue(all(p.value == "" for p in result.social_profiles.values()))
|
||||
|
||||
# Only the metadata the shell actually exposes is surfaced.
|
||||
self.assertEqual(out["company_name"]["value"], "Vite App")
|
||||
self.assertEqual(out["company_name"]["method"], "Title Tag")
|
||||
self.assertTrue(out["logo"]["value"].endswith("/vite.svg"))
|
||||
|
||||
def test_readability_diagnosed_empty(self):
|
||||
from crm.domain_enrichment import extractors
|
||||
|
||||
page, _soup = fixtures.make_page("https://spa.example", fixtures.JS_SPA_SHELL)
|
||||
self.assertEqual(extractors.diagnose_readability([page]), "empty")
|
||||
|
||||
|
||||
class SchemaStabilityTest(UnitTestCase):
|
||||
"""The result schema must be identical regardless of what a site exposes."""
|
||||
|
||||
def _assert_invariants(self, out, flat):
|
||||
self.assertEqual(set(out), EXPECTED_TOP_KEYS)
|
||||
self.assertEqual(set(flat), EXPECTED_FLAT_KEYS)
|
||||
for key in ("company_name", "description", "logo", "industry"):
|
||||
self.assertEqual(set(out[key]), SCALAR_SUBKEYS)
|
||||
|
||||
def test_empty_result_has_full_schema(self):
|
||||
result = EnrichmentResult()
|
||||
self._assert_invariants(result.to_dict(), result.flat())
|
||||
|
||||
def test_populated_result_has_same_schema(self):
|
||||
with mock.patch.object(pipeline_mod, "crawl", _canned_crawl):
|
||||
result = run_pipeline("https://acme.example", cfg=_full_config())
|
||||
out = result.to_dict()
|
||||
self._assert_invariants(out, result.flat())
|
||||
for e in out["emails"]:
|
||||
self.assertEqual(set(e), {"value", "source", "method"})
|
||||
for p in out["phones"]:
|
||||
self.assertEqual(set(p), {"value", "raw", "source", "method"})
|
||||
Reference in New Issue
Block a user