Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
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
406 lines
15 KiB
Python
406 lines
15 KiB
Python
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
|
# See license.txt
|
|
|
|
import frappe
|
|
from frappe.tests import IntegrationTestCase
|
|
|
|
from crm.api import form as F
|
|
|
|
|
|
def make_form(route, document_type="CRM Lead", **kw):
|
|
"""Create a CRM form (native Web Form) and return its name."""
|
|
payload = {"title": kw.pop("title", "Test Form"), "route": route, "document_type": document_type}
|
|
payload.update(kw)
|
|
return F.save_form(name=None, form=payload)["name"]
|
|
|
|
|
|
class TestFormAPI(IntegrationTestCase):
|
|
def tearDown(self):
|
|
frappe.set_user("Administrator")
|
|
frappe.flags.in_web_form = False
|
|
frappe.form_dict.pop("web_form", None)
|
|
frappe.db.rollback()
|
|
|
|
# ---- field picker ----
|
|
|
|
def test_get_form_fields_returns_mappable_and_excludes_breaks(self):
|
|
"""The picker returns collectible fields (first_name) and drops
|
|
layout/no-value fields (Section Break) and unsupported types (Link)."""
|
|
fields = F.get_form_fields("CRM Lead")
|
|
names = {f["fieldname"] for f in fields}
|
|
types = {f["fieldtype"] for f in fields}
|
|
self.assertIn("first_name", names)
|
|
self.assertNotIn("Section Break", types)
|
|
self.assertNotIn("Column Break", types)
|
|
# status is a Link — not collectible on a public form
|
|
self.assertNotIn("status", names)
|
|
|
|
def test_get_form_fields_rejects_unsupported_doctype(self):
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.get_form_fields("User")
|
|
|
|
# ---- create / read config + seeding ----
|
|
|
|
def test_new_lead_form_seeds_default_layout(self):
|
|
"""A brand-new Lead form is seeded with a contact-capture layout and a
|
|
hidden Status field with a default."""
|
|
name = make_form("seed-lead")
|
|
cfg = F.get_form_config(name)
|
|
|
|
visible = [
|
|
f["fieldname"] for f in cfg["fields"] if f["fieldtype"] not in ("Section Break", "Column Break")
|
|
]
|
|
self.assertEqual(visible, ["first_name", "email", "last_name", "phone"])
|
|
# section + column break markers are present
|
|
self.assertIn("Section Break", [f["fieldtype"] for f in cfg["fields"]])
|
|
self.assertIn("Column Break", [f["fieldtype"] for f in cfg["fields"]])
|
|
# first_name is mandatory on Lead
|
|
first = next(f for f in cfg["fields"] if f["fieldname"] == "first_name")
|
|
self.assertTrue(first["reqd"])
|
|
# hidden Status seeded with a non-empty default
|
|
hidden = {h["fieldname"]: h for h in cfg["hidden_fields"]}
|
|
self.assertIn("status", hidden)
|
|
self.assertTrue(hidden["status"]["default"])
|
|
|
|
def test_new_deal_form_seeds_organization(self):
|
|
name = make_form("seed-deal", document_type="CRM Deal")
|
|
cfg = F.get_form_config(name)
|
|
visible = [
|
|
f["fieldname"] for f in cfg["fields"] if f["fieldtype"] not in ("Section Break", "Column Break")
|
|
]
|
|
self.assertIn("organization_name", visible)
|
|
hidden = {h["fieldname"] for h in cfg["hidden_fields"]}
|
|
self.assertIn("status", hidden)
|
|
|
|
def test_save_form_persists_edits(self):
|
|
name = make_form("edit-me")
|
|
F.save_form(
|
|
name=name,
|
|
form={
|
|
"title": "Edited",
|
|
"route": "edit-me",
|
|
"document_type": "CRM Lead",
|
|
"description": "hello",
|
|
"submit_button_label": "Go",
|
|
"fields": [{"fieldname": "first_name", "label": "Name", "fieldtype": "Data", "reqd": 1}],
|
|
"hidden_fields": [],
|
|
},
|
|
)
|
|
cfg = F.get_form_config(name)
|
|
self.assertEqual(cfg["title"], "Edited")
|
|
self.assertEqual(cfg["description"], "hello")
|
|
self.assertEqual(cfg["submit_button_label"], "Go")
|
|
self.assertEqual([f["fieldname"] for f in cfg["fields"]], ["first_name"])
|
|
|
|
def test_redirect_url_round_trips(self):
|
|
name = make_form("redir")
|
|
F.save_form(
|
|
name=name,
|
|
form={
|
|
"title": "R",
|
|
"route": "redir",
|
|
"document_type": "CRM Lead",
|
|
"redirect_url": "https://example.com/thanks",
|
|
"fields": [],
|
|
"hidden_fields": [],
|
|
},
|
|
)
|
|
self.assertEqual(F.get_form_config(name)["redirect_url"], "https://example.com/thanks")
|
|
self.assertEqual(frappe.db.get_value("Web Form", name, "success_url"), "https://example.com/thanks")
|
|
|
|
# ---- publish guard ----
|
|
|
|
def test_publish_blocked_when_hidden_default_missing(self):
|
|
name = make_form("pub-guard")
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.save_form(
|
|
name=name,
|
|
form={
|
|
"title": "P",
|
|
"route": "pub-guard",
|
|
"document_type": "CRM Lead",
|
|
"published": 1,
|
|
"fields": [{"fieldname": "first_name", "fieldtype": "Data", "reqd": 1}],
|
|
"hidden_fields": [{"fieldname": "status", "fieldtype": "Link", "default": ""}],
|
|
},
|
|
)
|
|
|
|
def test_set_published_blocked_when_hidden_default_missing(self):
|
|
name = make_form("setpub-guard")
|
|
# stuff a hidden field with a blank default, still a draft
|
|
F.save_form(
|
|
name=name,
|
|
form={
|
|
"title": "P",
|
|
"route": "setpub-guard",
|
|
"document_type": "CRM Lead",
|
|
"published": 0,
|
|
"fields": [{"fieldname": "first_name", "fieldtype": "Data", "reqd": 1}],
|
|
"hidden_fields": [{"fieldname": "status", "fieldtype": "Link", "default": ""}],
|
|
},
|
|
)
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.set_published(name, 1)
|
|
|
|
# ---- submission enrichment ----
|
|
|
|
def test_enrichment_stamps_source_and_hidden_default_on_web_form_insert(self):
|
|
"""A Lead created through the web-form path — the framework's accept() sets
|
|
`in_web_form` and puts the form name in form_dict — is stamped
|
|
Source = Web Form and gets its hidden Status default applied by the
|
|
before_insert hook (enrich_form_submission)."""
|
|
name = make_form("enrich-lead") # seeds a hidden Status field with a default
|
|
|
|
frappe.flags.in_web_form = True
|
|
frappe.form_dict["web_form"] = name
|
|
lead = frappe.get_doc(
|
|
{"doctype": "CRM Lead", "first_name": "Ada", "email": "wf-test-ada@test.invalid"}
|
|
).insert(ignore_permissions=True)
|
|
|
|
self.assertEqual(lead.source, "Web Form")
|
|
self.assertTrue(lead.status) # hidden Status default applied
|
|
|
|
def test_enrichment_skipped_without_web_form_flag(self):
|
|
"""Outside the web-form path, the hook is a no-op — a normally created Lead
|
|
is not stamped with the Web Form source."""
|
|
lead = frappe.get_doc(
|
|
{"doctype": "CRM Lead", "first_name": "Bob", "email": "wf-test-bob@test.invalid"}
|
|
).insert(ignore_permissions=True)
|
|
self.assertNotEqual(lead.source, "Web Form")
|
|
|
|
def test_hidden_defaults_ignored_when_form_targets_other_doctype(self):
|
|
"""A submission referencing a form for a different doctype must not pull in
|
|
that form's hidden defaults — guards against a spoofed `web_form` param."""
|
|
deal_form = make_form("spoof-deal", document_type="CRM Deal") # seeds Status = Qualification
|
|
|
|
frappe.flags.in_web_form = True
|
|
frappe.form_dict["web_form"] = deal_form # a Deal form, but we insert a Lead
|
|
lead = frappe.get_doc(
|
|
{"doctype": "CRM Lead", "first_name": "Eve", "email": "wf-test-eve@test.invalid"}
|
|
).insert(ignore_permissions=True)
|
|
|
|
# "Qualification" is a Deal status; the guard must keep it off the Lead
|
|
self.assertNotEqual(lead.status, "Qualification")
|
|
|
|
# ---- field conditional logic ----
|
|
|
|
def test_save_form_persists_field_conditional_rules(self):
|
|
"""depends_on / mandatory_depends_on / read_only_depends_on authored per field in
|
|
the builder are stored on the Web Form fields and returned by get_form_config, so
|
|
the public page can honour them (same model as the framework's Web Form)."""
|
|
name = make_form(
|
|
"dep-rules",
|
|
fields=[
|
|
{"fieldname": "organization", "fieldtype": "Data"},
|
|
{"fieldname": "website", "fieldtype": "Data", "depends_on": "eval:doc.organization"},
|
|
{
|
|
"fieldname": "phone",
|
|
"fieldtype": "Data",
|
|
"mandatory_depends_on": 'eval:doc.organization == "Acme"',
|
|
},
|
|
{
|
|
"fieldname": "last_name",
|
|
"fieldtype": "Data",
|
|
"read_only_depends_on": "eval:doc.first_name",
|
|
},
|
|
],
|
|
hidden_fields=[],
|
|
)
|
|
by = {f.fieldname: f for f in frappe.get_doc("Web Form", name).web_form_fields}
|
|
self.assertEqual(by["website"].depends_on, "eval:doc.organization")
|
|
self.assertEqual(by["phone"].mandatory_depends_on, 'eval:doc.organization == "Acme"')
|
|
self.assertEqual(by["last_name"].read_only_depends_on, "eval:doc.first_name")
|
|
self.assertFalse(by["organization"].depends_on)
|
|
|
|
cfg = {f["fieldname"]: f for f in F.get_form_config(name)["fields"]}
|
|
self.assertEqual(cfg["website"]["depends_on"], "eval:doc.organization")
|
|
self.assertEqual(cfg["phone"]["mandatory_depends_on"], 'eval:doc.organization == "Acme"')
|
|
self.assertEqual(cfg["last_name"]["read_only_depends_on"], "eval:doc.first_name")
|
|
|
|
# ---- layout persistence ----
|
|
|
|
def test_save_without_fields_key_preserves_layout(self):
|
|
"""An update that omits `fields` (e.g. a settings-only save) keeps the existing
|
|
layout instead of wiping it."""
|
|
name = make_form("keep-layout")
|
|
before = len(F.get_form_config(name)["fields"])
|
|
self.assertTrue(before) # seeded layout exists
|
|
|
|
F.save_form(name=name, form={"title": "Renamed", "route": "keep-layout", "document_type": "CRM Lead"})
|
|
|
|
self.assertEqual(len(F.get_form_config(name)["fields"]), before)
|
|
|
|
# ---- field allowlist ----
|
|
|
|
def test_save_form_rejects_denied_field(self):
|
|
"""A payload can't map an internal field the picker excludes."""
|
|
name = make_form("denied-field")
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.save_form(
|
|
name=name,
|
|
form={
|
|
"title": "D",
|
|
"route": "denied-field",
|
|
"document_type": "CRM Lead",
|
|
"fields": [{"fieldname": "converted", "fieldtype": "Check"}],
|
|
"hidden_fields": [],
|
|
},
|
|
)
|
|
|
|
def test_save_form_rejects_layout_break_shadowing_a_field(self):
|
|
"""A break skips the catalog check, so it must not name a real field."""
|
|
name = make_form("break-shadow")
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.save_form(
|
|
name=name,
|
|
form={
|
|
"title": "B",
|
|
"route": "break-shadow",
|
|
"document_type": "CRM Lead",
|
|
"fields": [{"fieldname": "converted", "fieldtype": "Section Break"}],
|
|
"hidden_fields": [],
|
|
},
|
|
)
|
|
|
|
def test_save_form_rejects_unknown_hidden_field(self):
|
|
"""Only the system-managed mandatory fields may be hidden fields."""
|
|
name = make_form("hidden-denied")
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.save_form(
|
|
name=name,
|
|
form={
|
|
"title": "H",
|
|
"route": "hidden-denied",
|
|
"document_type": "CRM Lead",
|
|
"fields": [{"fieldname": "first_name", "fieldtype": "Data"}],
|
|
"hidden_fields": [{"fieldname": "converted", "fieldtype": "Check", "default": "1"}],
|
|
},
|
|
)
|
|
|
|
def test_save_form_takes_fieldtype_from_catalog(self):
|
|
"""fieldtype and options come from the doctype, not the payload."""
|
|
name = make_form(
|
|
"spoof-type",
|
|
fields=[{"fieldname": "first_name", "fieldtype": "Attach", "options": "User"}],
|
|
hidden_fields=[],
|
|
)
|
|
stored = frappe.get_doc("Web Form", name).web_form_fields[0]
|
|
self.assertEqual(stored.fieldname, "first_name")
|
|
self.assertEqual(stored.fieldtype, "Data")
|
|
self.assertFalse(stored.options)
|
|
|
|
# ---- draft dry-run ----
|
|
|
|
def test_test_submit_validates_but_creates_nothing(self):
|
|
name = make_form("dry-run")
|
|
before = frappe.db.count("CRM Lead")
|
|
res = F.test_submit_form(name, {"first_name": "Dry"})
|
|
self.assertTrue(res["test"])
|
|
self.assertEqual(frappe.db.count("CRM Lead"), before)
|
|
# still enforces required
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.test_submit_form(name, {})
|
|
|
|
# ---- scope ----
|
|
|
|
def test_non_crm_web_form_is_out_of_scope(self):
|
|
"""A Web Form from another module that happens to target CRM Lead must not be
|
|
listed, read, published, or deleted through the CRM forms API."""
|
|
other = frappe.get_doc(
|
|
{
|
|
"doctype": "Web Form",
|
|
"title": "Other App Form",
|
|
"route": "other-app-form",
|
|
"doc_type": "CRM Lead",
|
|
"module": "Core", # not FCRM
|
|
"is_standard": 0,
|
|
"web_form_fields": [{"fieldname": "first_name", "fieldtype": "Data", "label": "First Name"}],
|
|
}
|
|
).insert(ignore_permissions=True)
|
|
|
|
self.assertNotIn(other.name, [f["name"] for f in F.list_forms()])
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.get_form_config(other.name)
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.set_published(other.name, 1)
|
|
with self.assertRaises(frappe.ValidationError):
|
|
F.delete_form(other.name)
|
|
|
|
# ---- public page: Link options must not corrupt the author's session ----
|
|
|
|
def test_link_field_options_preserve_authenticated_session(self):
|
|
"""Rendering a Link field's guest-scoped options for a logged-in author's
|
|
preview must leave the author's live session untouched. Regression for the
|
|
preview-logout bug: frappe.set_user() overwrites session.sid (→ username) and
|
|
wipes session.data, so the enumerate-as-Guest path must snapshot and restore
|
|
them — otherwise the corrupted session is persisted under the real sid and the
|
|
author is demoted to Guest on their next request."""
|
|
from unittest.mock import patch
|
|
|
|
from crm.www import crm_form
|
|
|
|
# a realistic logged-in author session
|
|
frappe.set_user("Administrator")
|
|
frappe.session.sid = "regression_real_sid"
|
|
frappe.session.data = frappe._dict(
|
|
{
|
|
"user": "Administrator",
|
|
"sid": "regression_real_sid",
|
|
"data": frappe._dict({"session_expiry": "06:00:00", "csrf_token": "tok"}),
|
|
}
|
|
)
|
|
before_user, before_sid, before_data = (
|
|
frappe.session.user,
|
|
frappe.session.sid,
|
|
frappe.session.data,
|
|
)
|
|
|
|
# force the guest-enumeration branch (guest_can_select True); the actual
|
|
# query is irrelevant here — we're asserting the session dance is clean
|
|
with (
|
|
patch.object(crm_form, "guest_can_select", return_value=True),
|
|
patch("frappe.get_list", return_value=[]),
|
|
):
|
|
crm_form._link_field_options("CRM Industry")
|
|
|
|
self.assertEqual(frappe.session.user, before_user)
|
|
self.assertEqual(frappe.session.sid, before_sid) # not mangled to the username
|
|
self.assertIs(frappe.session.data, before_data) # nested data object preserved, not wiped
|
|
self.assertEqual(frappe.session.data.data.get("session_expiry"), "06:00:00")
|
|
|
|
def test_link_field_options_skip_switch_for_guest(self):
|
|
"""An anonymous visitor is already in the Guest context, so no session switch
|
|
happens — the query runs directly (set_user is never called)."""
|
|
from unittest.mock import patch
|
|
|
|
from crm.www import crm_form
|
|
|
|
frappe.set_user("Guest")
|
|
with (
|
|
patch.object(crm_form, "guest_can_select", return_value=True),
|
|
patch("frappe.get_list", return_value=[]),
|
|
patch("frappe.set_user") as mock_set_user,
|
|
):
|
|
crm_form._link_field_options("CRM Industry")
|
|
mock_set_user.assert_not_called()
|
|
|
|
# ---- permissions ----
|
|
|
|
def test_non_manager_cannot_manage_forms(self):
|
|
# a user without CRM-manager roles, to exercise the permission gate — created
|
|
# in-transaction (no commit) so tearDown's rollback cleans it up
|
|
if not frappe.db.exists("User", "sales-user@example.com"):
|
|
frappe.get_doc(
|
|
{
|
|
"doctype": "User",
|
|
"email": "sales-user@example.com",
|
|
"first_name": "Sales",
|
|
"roles": [{"role": "Sales User"}],
|
|
}
|
|
).insert(ignore_permissions=True)
|
|
frappe.set_user("sales-user@example.com")
|
|
with self.assertRaises(frappe.PermissionError):
|
|
F.list_forms()
|
|
with self.assertRaises(frappe.PermissionError):
|
|
F.save_form(name=None, form={"title": "X", "route": "nope", "document_type": "CRM Lead"})
|