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
194 lines
6.7 KiB
Python
194 lines
6.7 KiB
Python
# 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"})
|