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
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

This commit is contained in:
upstream-mirror
2026-09-18 01:36:01 +05:00
commit db69de1c11
905 changed files with 102713 additions and 0 deletions

22
crm/tests/__init__.py Normal file
View File

@@ -0,0 +1,22 @@
import json
import os
import frappe
def before_tests():
load_crm_user_test_records()
def load_crm_user_test_records():
"""Load CRM user test records from crm/tests/test_records.json"""
test_records_path = os.path.join(os.path.dirname(__file__), "test_records.json")
if os.path.exists(test_records_path):
with open(test_records_path) as f:
test_records = json.load(f)
for record in test_records:
if not frappe.db.exists("User", record.get("email")):
doc = frappe.get_doc(record)
doc.insert(ignore_permissions=True, ignore_if_duplicate=True)

View File

@@ -0,0 +1,119 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.permissions import add_permission, update_permission_property
from frappe.tests import IntegrationTestCase
from crm.api.activities import get_activities
RESTRICTED_FIELD = "permlevel_secret"
RESTRICTED_VALUE = "salary is 100000"
class TestActivityPermlevel(IntegrationTestCase):
"""A permlevel-restricted field is hidden on the form layout, so its values
must not leak through the activity timeline either (frappe/crm#805).
rep@permlevel.test -- Sales User, owns the lead, no permlevel 1 access
mgr@permlevel.test -- Sales Manager, reads permlevel 1
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
make_user("rep@permlevel.test", roles=["Sales User"])
make_user("mgr@permlevel.test", roles=["Sales Manager"])
if not frappe.db.exists("Custom Field", {"dt": "CRM Lead", "fieldname": RESTRICTED_FIELD}):
frappe.get_doc(
{
"doctype": "Custom Field",
"dt": "CRM Lead",
"fieldname": RESTRICTED_FIELD,
"label": "Permlevel Secret",
"fieldtype": "Data",
"permlevel": 1,
}
).insert(ignore_permissions=True)
# only Sales Manager gets permlevel 1 on CRM Lead
add_permission("CRM Lead", "Sales Manager", 1)
update_permission_property("CRM Lead", "Sales Manager", 1, "write", 1)
frappe.clear_cache(doctype="CRM Lead")
def setUp(self):
frappe.db.savepoint("test_activity_permlevel")
def tearDown(self):
frappe.set_user("Administrator")
frappe.db.rollback(save_point="test_activity_permlevel")
def make_lead_with_restricted_change(self):
lead = frappe.get_doc(
{"doctype": "CRM Lead", "lead_owner": "rep@permlevel.test", "first_name": "Permlevel"}
)
lead.flags.ignore_mandatory = True
lead.insert(ignore_permissions=True)
lead.set(RESTRICTED_FIELD, RESTRICTED_VALUE)
# the timeline is built from versions, which tests skip unless asked for
lead.save(ignore_permissions=True, ignore_version=False)
return lead
def activities_as(self, user, name):
frappe.set_user(user)
try:
activities, *_ = get_activities(name)
finally:
frappe.set_user("Administrator")
return activities
def test_restricted_field_change_is_hidden_from_activity(self):
lead = self.make_lead_with_restricted_change()
activities = self.activities_as("rep@permlevel.test", lead.name)
self.assertNotIn(RESTRICTED_FIELD, changed_fields(activities))
self.assertNotIn(RESTRICTED_VALUE, frappe.as_json(activities))
def test_permitted_user_still_sees_the_change(self):
lead = self.make_lead_with_restricted_change()
activities = self.activities_as("mgr@permlevel.test", lead.name)
self.assertIn(RESTRICTED_FIELD, changed_fields(activities))
def test_unrestricted_fields_are_untouched(self):
lead = self.make_lead_with_restricted_change()
lead.status = "Qualified"
lead.save(ignore_permissions=True, ignore_version=False)
activities = self.activities_as("rep@permlevel.test", lead.name)
self.assertIn("status", changed_fields(activities))
self.assertNotIn(RESTRICTED_FIELD, changed_fields(activities))
def changed_fields(activities):
"""Fieldnames reported by the timeline, including grouped versions."""
fields = set()
for activity in activities:
for entry in [activity, *(activity.get("other_versions") or [])]:
data = entry.get("data")
if isinstance(data, dict) and data.get("field"):
fields.add(data["field"])
return fields
def make_user(email, roles=None):
if frappe.db.exists("User", email):
return frappe.get_doc("User", email)
user = frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": email.split("@")[0],
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
for role in roles or []:
user.add_roles(role)
return user

831
crm/tests/test_dashboard.py Normal file
View File

@@ -0,0 +1,831 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from frappe.tests import IntegrationTestCase
from frappe.tests.utils import make_test_records
from frappe.utils import add_days, get_first_day, get_last_day, nowdate
from crm.api.dashboard import (
get_average_deal_value,
get_average_ongoing_deal_value,
get_average_time_to_close_a_deal,
get_average_time_to_close_a_lead,
get_average_won_deal_value,
get_base_currency_symbol,
get_chart,
get_chart_options,
get_dashboard,
get_deal_status_change_counts,
get_deals_by_salesperson,
get_deals_by_source,
get_deals_by_stage_axis,
get_deals_by_stage_donut,
get_deals_by_territory,
get_forecasted_revenue,
get_funnel_conversion,
get_leads_by_source,
get_lost_deal_reasons,
get_ongoing_deals,
get_sales_trend,
get_total_leads,
get_won_deals,
)
class TestDashboard(IntegrationTestCase):
@classmethod
def setUpClass(cls):
"""Set up test records once for all tests"""
super().setUpClass()
# Mark timestamp before creating test data
cls.test_start_time = frappe.utils.now()
cls.from_date = get_first_day(nowdate())
cls.to_date = get_last_day(nowdate())
cls.user = "crm.manager@example.com" # CRM manager from test_records.json
cls.user2_email = "crm.user1@example.com" # Test user from test_records.json
# Load test records from test_records.json files in dependency order
make_test_records("CRM Lead Status")
make_test_records("CRM Deal Status")
make_test_records("CRM Lead Source")
make_test_records("CRM Lost Reason")
make_test_records("CRM Organization") # Load organizations before deals
make_test_records("CRM Lead")
make_test_records("CRM Deal")
@classmethod
def tearDownClass(cls):
"""Clean up test records after all tests"""
frappe.db.rollback()
super().tearDownClass()
def test_get_total_leads(self):
"""Test get_total_leads returns correct lead count and delta calculation"""
result = get_total_leads(self.from_date, self.to_date)
# Verify actual count from test data
self.assertEqual(result["title"], "Total leads")
self.assertEqual(result["value"], 35) # 35 leads from test_records.json
self.assertIsInstance(result["delta"], (int, float))
self.assertEqual(result["deltaSuffix"], "%")
# Test with user filter - crm.user1@example.com owns 3 leads
result_user = get_total_leads(self.from_date, self.to_date, self.user2_email)
self.assertEqual(result_user["value"], 3)
self.assertLessEqual(result_user["value"], result["value"])
# Verify user's leads are subset of total
self.assertGreater(result["value"], result_user["value"])
def test_get_ongoing_deals(self):
"""Test get_ongoing_deals returns correct non-won/lost deal count"""
result = get_ongoing_deals(self.from_date, self.to_date)
# Verify actual count: 13 Qualification + 8 Negotiation = 21
self.assertEqual(result["title"], "Ongoing deals")
self.assertEqual(result["value"], 21)
# Verify it's not counting won/lost deals
all_deals = frappe.db.count("CRM Deal")
self.assertLess(result["value"], all_deals)
# Test with user filter - crm.user1@example.com owns 2 ongoing deals
result_user = get_ongoing_deals(self.from_date, self.to_date, self.user2_email)
self.assertEqual(result_user["value"], 2)
# Verify user owns subset of ongoing deals
self.assertLess(result_user["value"], result["value"])
def test_get_average_ongoing_deal_value(self):
"""Test get_average_ongoing_deal_value calculates correct average"""
result = get_average_ongoing_deal_value(self.from_date, self.to_date)
self.assertEqual(result["title"], "Avg. ongoing deal value")
# Expected average of ongoing deals (13 Qualification + 8 Negotiation = 21 deals)
# Qualification: 50k,75k,100k,25k,80k,60k,90k,45k,70k,55k,85k,65k,95k = 895,000 / 13 = 68,846.15
# Negotiation: 120k,110k,130k,105k,115k,125k,140k,135k = 980,000 / 8 = 122,500
# Combined: 1,875,000 / 21 = 89,285.71
expected_avg = 89285.71
self.assertAlmostEqual(result["value"], expected_avg, places=2)
self.assertIsNotNone(result["prefix"]) # Should have currency symbol
self.assertIsInstance(result["delta"], (int, float))
# Test with user filter - crm.user1@example.com owns 2 ongoing deals
result_user = get_average_ongoing_deal_value(self.from_date, self.to_date, self.user2_email)
# User1 has 2 deals: Cloud Systems (90k) + Smart Solutions (70k) = 160k / 2 = 80,000
expected_user_avg = 80000.0
self.assertAlmostEqual(result_user["value"], expected_user_avg, places=2)
# Both should have same currency symbol
self.assertEqual(result["prefix"], result_user["prefix"])
def test_get_won_deals(self):
"""Test get_won_deals returns correct won deal count"""
result = get_won_deals(self.from_date, self.to_date)
self.assertEqual(result["title"], "Won deals")
self.assertEqual(result["value"], 8) # 8 won deals from test_records.json
self.assertIsNotNone(result.get("tooltip"))
# Verify won deals is less than total deals
ongoing_result = get_ongoing_deals(self.from_date, self.to_date)
total_won_and_ongoing = result["value"] + ongoing_result["value"]
all_deals = frappe.db.count("CRM Deal")
self.assertLessEqual(total_won_and_ongoing, all_deals)
# Test with user filter - crm.user1@example.com owns 0 won deals
result_user = get_won_deals(self.from_date, self.to_date, self.user2_email)
self.assertEqual(result_user["value"], 0)
# User owns no won deals but has ongoing deals
ongoing_user = get_ongoing_deals(self.from_date, self.to_date, self.user2_email)
self.assertGreater(ongoing_user["value"], result_user["value"])
def test_get_average_won_deal_value(self):
"""Test get_average_won_deal_value calculates correct average for won deals"""
result = get_average_won_deal_value(self.from_date, self.to_date)
self.assertEqual(result["title"], "Avg. won deal value")
# Expected average of won deals: 8 deals
# 150k,160k,145k,170k,155k,165k,175k,180k = 1,300,000 / 8 = 162,500
expected_avg = 162500.0
won_count = get_won_deals(self.from_date, self.to_date)["value"]
if won_count > 0:
self.assertAlmostEqual(result["value"], expected_avg, places=2)
else:
self.assertEqual(result["value"], 0)
# Test with user filter - user2 has no won deals
result_user = get_average_won_deal_value(self.from_date, self.to_date, self.user2_email)
self.assertEqual(result_user["value"], 0) # No won deals = 0 average
# Verify currency consistency
self.assertEqual(result["prefix"], result_user["prefix"])
def test_get_average_deal_value(self):
"""Test get_average_deal_value for all non-lost deals includes won + ongoing"""
result = get_average_deal_value(self.from_date, self.to_date)
self.assertEqual(result["title"], "Avg. deal value")
# Expected average: Ongoing (21 deals: 13 Qualification + 8 Negotiation, $1,875k) + Won (8 deals, $1,300k)
# Total: 29 non-lost deals, $3,175k / 29 = 109,482.76
expected_avg = 109482.76
self.assertAlmostEqual(result["value"], expected_avg, places=2)
self.assertIn(
"ongoing & won", result["tooltip"].lower()
) # Verify tooltip describes ongoing & won deals
# Test with user filter - crm.user1@example.com owns 2 ongoing deals (no won)
result_user = get_average_deal_value(self.from_date, self.to_date, self.user2_email)
# User1 has 2 ongoing deals: 90k + 70k = 160k / 2 = 80,000
expected_user_avg = 80000.0
self.assertAlmostEqual(result_user["value"], expected_user_avg, places=2)
def test_get_average_time_to_close_a_lead(self):
"""Test get_average_time_to_close_a_lead calculates time from lead creation"""
result = get_average_time_to_close_a_lead(self.from_date, self.to_date)
self.assertEqual(result["title"], "Avg. time to close a lead")
self.assertEqual(result["value"], 0) # Test records created on same day
self.assertEqual(result["suffix"], " days")
# Test with user filter
result_user = get_average_time_to_close_a_lead(self.from_date, self.to_date, self.user2_email)
self.assertEqual(result_user["value"], 0) # Test records created on same day
def test_get_average_time_to_close_a_deal(self):
"""Test get_average_time_to_close_a_deal calculates time from deal creation"""
result = get_average_time_to_close_a_deal(self.from_date, self.to_date)
self.assertEqual(result["title"], "Avg. time to close a deal")
self.assertEqual(result["value"], 0) # Test records created on same day
self.assertEqual(result.get("suffix", ""), " days")
# Test with user filter
result_user = get_average_time_to_close_a_deal(self.from_date, self.to_date, self.user2_email)
self.assertEqual(result_user["value"], 0) # Test records created on same day
def test_get_sales_trend(self):
"""Test get_sales_trend returns correct time series data"""
result = get_sales_trend(self.from_date, self.to_date)
self.assertEqual(result["title"], "Sales trend")
self.assertEqual(len(result["series"]), 3) # leads, deals, won_deals
self.assertEqual(result["series"][0]["name"], "leads")
self.assertEqual(result["series"][1]["name"], "deals")
self.assertEqual(result["series"][2]["name"], "won_deals")
# Verify data points exist
self.assertIsInstance(result["data"], list)
if len(result["data"]) > 0:
# Each data point should have date and values
first_point = result["data"][0]
self.assertIsInstance(first_point, dict)
# Test with user filter
result_user = get_sales_trend(self.from_date, self.to_date, self.user2_email)
self.assertIn("data", result_user)
self.assertEqual(len(result_user["series"]), 3)
# User data should be subset of total data
self.assertLessEqual(len(result_user["data"]), len(result["data"]) if result["data"] else 0)
def test_get_forecasted_revenue(self):
"""Test get_forecasted_revenue returns forecasted vs actual revenue comparison"""
result = get_forecasted_revenue(self.from_date, self.to_date)
self.assertEqual(result["title"], "Forecasted revenue")
# Should have both forecasted and actual series
if result["series"]:
series_names = [s["name"] for s in result["series"]]
self.assertIn("forecasted", series_names)
self.assertIn("actual", series_names)
result_user = get_forecasted_revenue(self.from_date, self.to_date, self.user2_email)
self.assertIn("data", result_user)
self.assertIn("series", result_user)
def test_get_funnel_conversion(self):
"""Test get_funnel_conversion returns correct pipeline funnel data"""
result = get_funnel_conversion(self.from_date, self.to_date)
self.assertEqual(result["title"], "Funnel conversion")
self.assertGreater(len(result["data"]), 0)
# Verify funnel starts with Leads
self.assertEqual(result["data"][0]["stage"], "Leads")
self.assertEqual(result["data"][0]["count"], 35) # 35 leads from test_records.json
# Verify funnel stages are in order and counts decrease or stay same (funnel effect)
for i in range(len(result["data"]) - 1):
current_count = result["data"][i]["count"]
next_count = result["data"][i + 1]["count"]
# Each stage should have equal or fewer than previous (funnel narrows)
self.assertGreaterEqual(
current_count,
next_count,
f"Funnel should narrow or stay same: {result['data'][i]['stage']} ({current_count}) should be >= {result['data'][i + 1]['stage']} ({next_count})",
)
# Test with user filter - crm.user1@example.com owns 3 leads
result_user = get_funnel_conversion(self.from_date, self.to_date, self.user2_email)
self.assertIn("data", result_user)
self.assertGreater(len(result_user["data"]), 0)
self.assertEqual(result_user["data"][0]["stage"], "Leads")
self.assertEqual(result_user["data"][0]["count"], 3)
# User's funnel should be subset of total
for i in range(min(len(result["data"]), len(result_user["data"]))):
self.assertLessEqual(result_user["data"][i]["count"], result["data"][i]["count"])
def test_get_deals_by_stage_axis(self):
"""Test get_deals_by_stage_axis returns deal distribution by stage"""
result = get_deals_by_stage_axis(self.from_date, self.to_date)
self.assertEqual(result["title"], "Deals by ongoing & won stage")
# Should have data for stages
if result["data"]:
# Each entry should have stage name and count
for entry in result["data"]:
self.assertIn("stage", entry)
self.assertIn("count", entry) # API uses 'count' not 'deals'
self.assertGreater(entry["count"], 0)
result_user = get_deals_by_stage_axis(self.from_date, self.to_date, self.user2_email)
self.assertIsInstance(result_user["data"], list)
def test_get_deals_by_stage_donut(self):
"""Test get_deals_by_stage_donut returns proper donut chart data"""
result = get_deals_by_stage_donut(self.from_date, self.to_date)
self.assertEqual(result["title"], "Deals by stage")
# Donut chart should have proper structure
if result["data"]:
total_count = sum(
entry.get("count", 0) for entry in result["data"]
) # API uses 'count' not 'deals'
self.assertGreater(total_count, 0)
result_user = get_deals_by_stage_donut(self.from_date, self.to_date, self.user2_email)
self.assertIsInstance(result_user["data"], list)
def test_get_lost_deal_reasons(self):
"""Test get_lost_deal_reasons returns distribution of loss reasons"""
result = get_lost_deal_reasons(self.from_date, self.to_date)
self.assertEqual(result["title"], "Lost deal reasons")
# Lost reasons only apply to lost deals
if result["data"]:
for entry in result["data"]:
self.assertIn("reason", entry)
self.assertIn("count", entry)
result_user = get_lost_deal_reasons(self.from_date, self.to_date, self.user2_email)
self.assertIsInstance(result_user["data"], list)
def test_get_leads_by_source(self):
"""Test get_leads_by_source returns source distribution"""
result = get_leads_by_source(self.from_date, self.to_date)
self.assertEqual(result["title"], "Leads by source")
# Should have source data
if result["data"]:
total_leads = sum(entry.get("count", 0) for entry in result["data"]) # API uses 'count'
self.assertEqual(total_leads, 35) # Total leads from test data
result_user = get_leads_by_source(self.from_date, self.to_date, self.user2_email)
if result_user["data"]:
user_total = sum(entry.get("count", 0) for entry in result_user["data"]) # API uses 'count'
self.assertEqual(user_total, 3) # user1 owns 3 leads
def test_get_deals_by_source(self):
"""Test get_deals_by_source returns source distribution"""
result = get_deals_by_source(self.from_date, self.to_date)
self.assertEqual(result["title"], "Deals by source")
self.assertIsInstance(result["data"], list)
result_user = get_deals_by_source(self.from_date, self.to_date, self.user2_email)
self.assertIsInstance(result_user["data"], list)
def test_get_deals_by_territory(self):
"""Test get_deals_by_territory returns geographic distribution"""
result = get_deals_by_territory(self.from_date, self.to_date)
self.assertEqual(result["title"], "Deals by territory")
self.assertIsInstance(result["data"], list)
result_user = get_deals_by_territory(self.from_date, self.to_date, self.user2_email)
self.assertIsInstance(result_user["data"], list)
def test_get_deals_by_salesperson(self):
"""Test get_deals_by_salesperson returns per-user performance"""
result = get_deals_by_salesperson(self.from_date, self.to_date)
self.assertEqual(result["title"], "Deals by salesperson")
# Should show different salespeople
if result["data"]:
for entry in result["data"]:
self.assertIn("salesperson", entry)
self.assertIn("deals", entry)
result_user = get_deals_by_salesperson(self.from_date, self.to_date, self.user2_email)
self.assertIn("data", result_user)
def test_get_base_currency_symbol(self):
"""Test get_base_currency_symbol returns correct currency symbol"""
# Set USD as base currency
if not frappe.db.exists("FCRM Settings"):
frappe.get_doc(
{
"doctype": "FCRM Settings",
"currency": "USD",
}
).insert()
else:
frappe.db.set_single_value("FCRM Settings", "currency", "USD")
symbol = get_base_currency_symbol()
self.assertIsNotNone(symbol)
self.assertIsInstance(symbol, str)
self.assertGreater(len(symbol), 0) # Should not be empty
# Common currency symbols
self.assertIn(symbol, ["$", "€", "£", "¥", "₹", "USD"])
def test_get_dashboard(self):
"""Test get_dashboard returns complete layout with valid data"""
result = get_dashboard(self.from_date, self.to_date)
self.assertIsInstance(result, list)
self.assertGreater(len(result), 0) # Should have layout items
# Each layout item should have required fields
for item in result:
self.assertIn("name", item)
self.assertIsInstance(item["name"], str)
self.assertTrue(item["name"]) # Name should not be empty
def test_get_chart(self):
"""Test get_chart returns correct chart data for valid chart names"""
result = get_chart("total_leads", "number", self.from_date, self.to_date)
self.assertEqual(result["value"], 35) # Should match get_total_leads
self.assertIsInstance(result["value"], (int, float))
self.assertIsNotNone(result.get("title"))
def test_get_chart_invalid_name(self):
"""Test get_chart returns proper error for invalid chart name"""
result = get_chart("invalid_chart_name", "number", self.from_date, self.to_date)
self.assertIsNotNone(result.get("error"))
self.assertGreater(len(result["error"]), 0)
def test_get_deal_status_change_counts(self):
"""Test get_deal_status_change_counts returns status transition data"""
result = get_deal_status_change_counts(self.from_date, self.to_date)
self.assertIsInstance(result, list)
# May be empty if no status changes recorded
if result:
for entry in result:
self.assertIsInstance(entry, dict)
def test_user_filtering_isolation(self):
"""Test that user filtering correctly isolates data across metrics"""
result_crm_user = get_total_leads(self.from_date, self.to_date, self.user2_email)
result_all = get_total_leads(self.from_date, self.to_date, "")
self.assertEqual(result_crm_user["value"], 3) # crm.user1@example.com owns 3 leads
self.assertEqual(result_all["value"], 35) # 35 total leads
self.assertGreater(result_all["value"], result_crm_user["value"])
def test_date_range_filtering(self):
"""Test that date range filtering works correctly"""
result_current = get_total_leads(self.from_date, self.to_date)
self.assertIsNotNone(result_current["value"])
self.assertGreater(result_current["value"], 0) # Should have leads created
# ============================================================
# EDGE CASE TESTS - Testing boundary conditions and errors
# ============================================================
def test_empty_date_range(self):
"""Test behavior with empty/future date range"""
# Future date range with no data
future_start = add_days(nowdate(), 365)
future_end = add_days(nowdate(), 400)
result = get_total_leads(future_start, future_end)
self.assertEqual(result["value"], 0)
# Should handle gracefully without errors
result_deals = get_ongoing_deals(future_start, future_end)
self.assertEqual(result_deals["value"], 0)
def test_invalid_date_order(self):
"""Test with end date before start date"""
# Swap dates - end before start
result = get_total_leads(self.to_date, self.from_date)
# Should still work (function handles it)
self.assertIsInstance(result["value"], (int, float))
def test_nonexistent_user_filter(self):
"""Test filtering by non-existent user"""
result = get_total_leads(self.from_date, self.to_date, "nonexistent@example.com")
# Should return 0 for non-existent user
self.assertEqual(result["value"], 0)
def test_chart_with_empty_name(self):
"""Test get_chart with empty chart name"""
result = get_chart("", "number", self.from_date, self.to_date)
self.assertIsNotNone(result.get("error"))
def test_chart_with_none_values(self):
"""Test get_chart handles None parameters gracefully"""
result = get_chart("total_leads", "number", None, None)
# Should handle gracefully
self.assertTrue("value" in result or "error" in result)
# ============================================================
# BUSINESS LOGIC VALIDATION TESTS
# ============================================================
def test_deal_counts_consistency(self):
"""Test that ongoing + won + lost deals = total deals"""
ongoing = get_ongoing_deals(self.from_date, self.to_date)["value"]
won = get_won_deals(self.from_date, self.to_date)["value"]
# Get lost deals count
lost_deals = frappe.db.count(
"CRM Deal",
{
"creation": ["between", [self.from_date, self.to_date]],
"status": ["in", frappe.db.get_list("CRM Deal Status", {"type": "Lost"}, pluck="name")],
},
)
total_deals_by_type = ongoing + won + lost_deals
total_deals = frappe.db.count("CRM Deal", {"creation": ["between", [self.from_date, self.to_date]]})
# Total should match sum of all deal types
self.assertEqual(
total_deals_by_type,
total_deals,
f"Deal count mismatch: ongoing({ongoing}) + won({won}) + lost({lost_deals}) = {total_deals_by_type}, but total is {total_deals}",
)
def test_average_values_are_reasonable(self):
"""Test that calculated averages match expected values from test data"""
avg_ongoing = get_average_ongoing_deal_value(self.from_date, self.to_date)["value"]
avg_won = get_average_won_deal_value(self.from_date, self.to_date)["value"]
avg_all = get_average_deal_value(self.from_date, self.to_date)["value"]
# Verify calculated averages match expected values
self.assertAlmostEqual(avg_ongoing, 89285.71, places=2) # $1,875,000 / 21 ongoing deals
self.assertAlmostEqual(avg_won, 162500.0, places=2) # $1,300,000 / 8 won deals
self.assertAlmostEqual(avg_all, 109482.76, places=2) # $3,175,000 / 29 non-lost deals
# Business logic: won deals should have higher average than ongoing
self.assertGreater(avg_won, avg_ongoing, "Won deals should have higher average than ongoing")
# Average of all should be between ongoing and won
self.assertGreater(avg_all, avg_ongoing, "All deals average should be greater than ongoing only")
self.assertLess(avg_all, avg_won, "All deals average should be less than won only")
def test_delta_calculation_logic(self):
"""Test that delta values represent actual change"""
result = get_total_leads(self.from_date, self.to_date)
# Delta should exist and be a number
self.assertIn("delta", result)
self.assertIsInstance(result["delta"], (int, float))
# Delta suffix should indicate percentage
if "deltaSuffix" in result:
self.assertEqual(result["deltaSuffix"], "%")
def test_currency_symbol_consistency(self):
"""Test that all value-based metrics use same currency symbol"""
symbol1 = get_average_ongoing_deal_value(self.from_date, self.to_date).get("prefix")
symbol2 = get_average_won_deal_value(self.from_date, self.to_date).get("prefix")
symbol3 = get_average_deal_value(self.from_date, self.to_date).get("prefix")
symbol4 = get_base_currency_symbol()
# All should use same currency
self.assertEqual(symbol1, symbol2)
self.assertEqual(symbol2, symbol3)
self.assertEqual(symbol3, symbol4)
def test_user_isolation_across_multiple_metrics(self):
"""Test that user filtering works consistently across all metrics"""
user = self.user2_email
# Get counts for user
user_leads = get_total_leads(self.from_date, self.to_date, user)["value"]
user_ongoing = get_ongoing_deals(self.from_date, self.to_date, user)["value"]
user_won = get_won_deals(self.from_date, self.to_date, user)["value"]
# Get total counts
total_leads = get_total_leads(self.from_date, self.to_date)["value"]
total_ongoing = get_ongoing_deals(self.from_date, self.to_date)["value"]
total_won = get_won_deals(self.from_date, self.to_date)["value"]
# User counts should be subset of totals
self.assertLessEqual(user_leads, total_leads)
self.assertLessEqual(user_ongoing, total_ongoing)
self.assertLessEqual(user_won, total_won)
# Verify specific user data matches expected
self.assertEqual(user_leads, 3) # crm.user1 owns 3 leads
self.assertEqual(user_ongoing, 2) # crm.user1 owns 2 ongoing deals
self.assertEqual(user_won, 0) # crm.user1 owns 0 won deals
def test_time_to_close_calculations(self):
"""Test that time to close metrics calculate correctly"""
lead_time = get_average_time_to_close_a_lead(self.from_date, self.to_date)
deal_time = get_average_time_to_close_a_deal(self.from_date, self.to_date)
# Should have correct structure
self.assertIn("value", lead_time)
self.assertIn("suffix", lead_time)
self.assertEqual(lead_time["suffix"], " days")
self.assertIn("value", deal_time)
self.assertIn("suffix", deal_time)
self.assertEqual(deal_time["suffix"], " days")
# Values should be non-negative
self.assertGreaterEqual(lead_time["value"], 0)
self.assertGreaterEqual(deal_time["value"], 0)
# negativeIsBetter flag should be present (faster is better)
if "negativeIsBetter" in lead_time:
self.assertTrue(lead_time["negativeIsBetter"])
if "negativeIsBetter" in deal_time:
self.assertTrue(deal_time["negativeIsBetter"])
def test_chart_data_types(self):
"""Test that chart types return appropriate data structures"""
# Number chart
number_chart = get_chart("total_leads", "number", self.from_date, self.to_date)
self.assertIn("value", number_chart)
self.assertIsInstance(number_chart["value"], (int, float))
# Test with different chart names
charts = ["ongoing_deals", "won_deals", "total_leads"]
for chart_name in charts:
result = get_chart(chart_name, "number", self.from_date, self.to_date)
self.assertIn("value", result, f"Chart {chart_name} missing value")
self.assertIsInstance(result["value"], (int, float), f"Chart {chart_name} value not numeric")
def test_dashboard_layout_structure(self):
"""Test that dashboard returns valid layout with all required fields"""
dashboard = get_dashboard(self.from_date, self.to_date)
# Should be list of layout items
self.assertIsInstance(dashboard, list)
# Each item should have required fields
for item in dashboard:
self.assertIn("name", item)
# Validate name is not empty
self.assertTrue(item["name"])
# Captured at module import, before any test patches frappe.get_hooks, so
# tests can delegate to the real implementation for every hook except the
# one they're specifically overriding.
_real_get_hooks = frappe.get_hooks
def _hook_side_effect(crm_dashboard_charts):
"""Build a frappe.get_hooks side_effect that only overrides the
crm_dashboard_charts hook and forwards every other hook lookup (used
throughout permission checks, whitelist processing, etc.) to the real
implementation -- patching frappe.get_hooks globally would otherwise
risk breaking unrelated machinery mid-request."""
def side_effect(hook=None, default="_KEEP_DEFAULT_LIST", app_name=None):
if hook == "crm_dashboard_charts":
return crm_dashboard_charts
return _real_get_hooks(hook, default, app_name)
return side_effect
class TestDashboardContributedCharts(IntegrationTestCase):
"""Test the crm_dashboard_charts hook that lets other apps contribute
dashboard chart options and their resolvers."""
def setUp(self):
self.from_date = get_first_day(nowdate())
self.to_date = get_last_day(nowdate())
def test_get_chart_options_has_no_contributions_by_default(self):
"""With no app registering the hook, get_hooks returns [] (not {}) --
get_chart_options must not choke on that."""
options = get_chart_options()
self.assertIn("total_leads", [o["value"] for o in options["number_chart"]])
@patch("frappe.get_hooks")
def test_contributed_chart_appears_in_options(self, mock_get_hooks):
mock_get_hooks.side_effect = _hook_side_effect(
{
"number_chart": [
{
"label": "Total Calls",
"value": "total_calls_made",
"resolver": "crm.api.dashboard.get_total_leads",
},
],
}
)
options = get_chart_options()
self.assertIn("total_calls_made", [o["value"] for o in options["number_chart"]])
# resolver is internal wiring, not something the frontend needs
contributed_option = next(o for o in options["number_chart"] if o["value"] == "total_calls_made")
self.assertNotIn("resolver", contributed_option)
@patch("frappe.get_hooks")
def test_contributed_chart_resolves_via_get_chart(self, mock_get_hooks):
mock_get_hooks.side_effect = _hook_side_effect(
{
"number_chart": [
{
"label": "Aliased Leads",
"value": "aliased_leads",
# Resolver points at a real, installed-app function so
# frappe.get_attr's installed-app check is satisfied --
# reusing get_total_leads is enough to prove the dispatch
# reaches a resolver outside get_chart's own hasattr check.
"resolver": "crm.api.dashboard.get_total_leads",
},
],
}
)
result = get_chart("aliased_leads", "number_chart", self.from_date, self.to_date)
self.assertNotIn("error", result)
self.assertEqual(result["title"], "Total leads")
@patch("frappe.get_hooks")
def test_multiple_apps_can_contribute_simultaneously(self, mock_get_hooks):
"""Unlike override_whitelisted_methods (last override wins), the hook
must let every contributing app's charts resolve at the same time."""
mock_get_hooks.side_effect = _hook_side_effect(
{
"number_chart": [
{
"label": "App B Chart",
"value": "app_b_chart",
"resolver": "crm.api.dashboard.get_total_leads",
},
{
"label": "App C Chart",
"value": "app_c_chart",
"resolver": "crm.api.dashboard.get_won_deals",
},
],
}
)
result_b = get_chart("app_b_chart", "number_chart", self.from_date, self.to_date)
result_c = get_chart("app_c_chart", "number_chart", self.from_date, self.to_date)
self.assertEqual(result_b["title"], "Total leads")
self.assertEqual(result_c["title"], "Won deals")
@patch("frappe.get_hooks")
def test_contributed_chart_cannot_shadow_core_chart(self, mock_get_hooks):
"""A contributed value matching a core chart name must never win --
core's own resolver keeps handling it."""
mock_get_hooks.side_effect = _hook_side_effect(
{
"number_chart": [
{
"label": "Hijacked",
"value": "total_leads",
"resolver": "crm.api.dashboard.get_won_deals",
},
],
}
)
options = get_chart_options()
total_leads_options = [o for o in options["number_chart"] if o["value"] == "total_leads"]
self.assertEqual(len(total_leads_options), 1)
self.assertEqual(total_leads_options[0]["label"], "Total Leads")
result = get_chart("total_leads", "number_chart", self.from_date, self.to_date)
self.assertEqual(result["title"], "Total leads") # core's own, not "Won deals"
@patch("frappe.get_hooks")
def test_unknown_chart_type_from_hook_is_ignored(self, mock_get_hooks):
"""Contributing a new top-level chart type isn't supported (DashboardItem.vue
has no renderer for it) -- such contributions must be silently dropped,
not raise or leak into an unsupported type."""
mock_get_hooks.side_effect = _hook_side_effect(
{
"pie_chart": [
{
"label": "Something",
"value": "something",
"resolver": "crm.api.dashboard.get_total_leads",
},
],
}
)
options = get_chart_options()
self.assertNotIn("pie_chart", options)
@patch("frappe.get_hooks")
def test_malformed_contribution_is_ignored(self, mock_get_hooks):
"""Missing label/value/resolver must be skipped, not raise."""
mock_get_hooks.side_effect = _hook_side_effect(
{
"number_chart": [
{"label": "No value or resolver"},
{"value": "no_label", "resolver": "crm.api.dashboard.get_total_leads"},
{"label": "No resolver", "value": "no_resolver"},
],
}
)
options = get_chart_options()
contributed_values = {o["value"] for o in options["number_chart"]} - {
"total_leads",
"ongoing_deals",
"average_ongoing_deal_value",
"won_deals",
"average_won_deal_value",
"average_deal_value",
"average_time_to_close_a_lead",
"average_time_to_close_a_deal",
}
self.assertEqual(contributed_values, set())

129
crm/tests/test_demo_data.py Normal file
View File

@@ -0,0 +1,129 @@
import json
import os
import frappe
from frappe.tests import IntegrationTestCase
class TestDemoData(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
from crm.demo.api import clear_demo_data
clear_demo_data()
def _check_demo_records_exist(self, doctype, record_names):
"""Helper method to check if specific demo records exist"""
if not record_names:
return False
for name in record_names:
if frappe.db.exists(doctype, name):
return True
return False
def test_demo_data_lifecycle(self):
from crm.demo.api import clear_demo_data, create_demo_data
from crm.demo.users import DEMO_USERS
DEMO_STATE_KEY = "crm_demo_data_created"
DEMO_LEADS_KEY = "crm_demo_leads"
DEMO_NOTES_KEY = "crm_demo_notes"
DEMO_TASKS_KEY = "crm_demo_tasks"
DEMO_CALL_LOGS_KEY = "crm_demo_call_logs"
DEMO_ACTIVITIES_KEY = "crm_demo_activities"
DEMO_DEALS_KEY = "crm_demo_deals"
# 1. Before creation: nothing should exist
for user in DEMO_USERS:
self.assertFalse(frappe.db.exists("User", user["email"]))
# Check that demo data defaults are not set
self.assertIsNone(frappe.db.get_default(DEMO_LEADS_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_NOTES_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_TASKS_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_CALL_LOGS_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_DEALS_KEY))
# 2. Create demo data
create_demo_data()
# Users
for user in DEMO_USERS:
doc = frappe.get_doc("User", user["email"])
self.assertIsNotNone(doc)
self.assertEqual(doc.user_image, user["avatar"])
self.assertTrue(doc.enabled)
self.assertEqual(doc.first_name, user["first_name"])
self.assertEqual(doc.last_name, user["last_name"])
# Leads - check that demo leads were created
demo_lead_names = json.loads(frappe.db.get_default(DEMO_LEADS_KEY) or "[]")
self.assertEqual(len(demo_lead_names), 12)
for lead_name in demo_lead_names:
lead = frappe.get_doc("CRM Lead", lead_name)
self.assertTrue(lead.first_name)
self.assertTrue(lead.organization)
# Notes, Tasks, Call Logs, Activities, Deals - check that demo data was created
demo_note_names = json.loads(frappe.db.get_default(DEMO_NOTES_KEY) or "[]")
demo_task_names = json.loads(frappe.db.get_default(DEMO_TASKS_KEY) or "[]")
demo_call_log_names = json.loads(frappe.db.get_default(DEMO_CALL_LOGS_KEY) or "[]")
demo_deal_data = json.loads(frappe.db.get_default(DEMO_DEALS_KEY) or "{}")
self.assertGreater(len(demo_note_names), 0)
self.assertGreater(len(demo_task_names), 0)
self.assertGreater(len(demo_call_log_names), 0)
if isinstance(demo_deal_data, dict):
self.assertGreater(len(demo_deal_data.get("deals", [])), 0)
# Avatars exist
avatar_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "crm", "public", "images", "demo")
)
for user in DEMO_USERS:
filename = user["avatar"].split("/")[-1]
path = os.path.join(avatar_dir, filename)
self.assertTrue(os.path.exists(path), f"Missing avatar: {path}")
# Site defaults set
self.assertEqual(frappe.db.get_default(DEMO_STATE_KEY), "1")
self.assertTrue(frappe.db.get_default(DEMO_LEADS_KEY))
self.assertTrue(frappe.db.get_default(DEMO_NOTES_KEY))
self.assertTrue(frappe.db.get_default(DEMO_TASKS_KEY))
self.assertTrue(frappe.db.get_default(DEMO_CALL_LOGS_KEY))
self.assertTrue(frappe.db.get_default(DEMO_ACTIVITIES_KEY))
self.assertTrue(frappe.db.get_default(DEMO_DEALS_KEY))
# 3. Capture demo record names before clearing
lead_names = json.loads(frappe.db.get_default(DEMO_LEADS_KEY) or "[]")
note_names = json.loads(frappe.db.get_default(DEMO_NOTES_KEY) or "[]")
task_names = json.loads(frappe.db.get_default(DEMO_TASKS_KEY) or "[]")
call_log_names = json.loads(frappe.db.get_default(DEMO_CALL_LOGS_KEY) or "[]")
deal_data = json.loads(frappe.db.get_default(DEMO_DEALS_KEY) or "{}")
# Clear demo data
clear_demo_data()
# All demo data should be gone - check using the tracked record names
# Users should be deleted
for user in DEMO_USERS:
self.assertFalse(frappe.db.exists("User", user["email"]))
# Demo records should not exist
self.assertFalse(self._check_demo_records_exist("CRM Lead", lead_names))
self.assertFalse(self._check_demo_records_exist("FCRM Note", note_names))
self.assertFalse(self._check_demo_records_exist("CRM Task", task_names))
self.assertFalse(self._check_demo_records_exist("CRM Call Log", call_log_names))
if isinstance(deal_data, dict) and deal_data.get("deals"):
self.assertFalse(self._check_demo_records_exist("CRM Deal", deal_data.get("deals", [])))
# Site defaults cleared
self.assertIsNone(frappe.db.get_default(DEMO_STATE_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_LEADS_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_NOTES_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_TASKS_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_CALL_LOGS_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_ACTIVITIES_KEY))
self.assertIsNone(frappe.db.get_default(DEMO_DEALS_KEY))

View File

@@ -0,0 +1,183 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from unittest.mock import MagicMock, patch
import frappe
from frappe.tests.utils import FrappeTestCase
from crm.api.exchange_rate import (
_fetch_from_exchangerate_api,
_fetch_from_exchangerate_host,
_fetch_from_fawaz_api,
_fetch_from_frankfurter,
get_exchange_rate,
)
def _make_response(ok: bool, json_data: dict) -> MagicMock:
"""Helper: build a fake requests.Response."""
mock = MagicMock()
mock.ok = ok
mock.json.return_value = json_data
return mock
def _mock_settings(provider: str, access_key: str = "") -> MagicMock:
"""Helper: build a fake FCRM Settings single document."""
settings = MagicMock()
settings.service_provider = provider
settings.access_key = access_key
return settings
class TestFetchFromFrankfurter(FrappeTestCase):
@patch("crm.api.exchange_rate.requests.get")
def test_returns_rate_on_success(self, mock_get):
mock_get.return_value = _make_response(True, {"rates": {"INR": 83.5}})
rate = _fetch_from_frankfurter("USD", "INR", "latest")
self.assertEqual(rate, 83.5)
mock_get.assert_called_once()
@patch("crm.api.exchange_rate.requests.get")
def test_returns_none_when_response_not_ok(self, mock_get):
mock_get.return_value = _make_response(False, {})
rate = _fetch_from_frankfurter("USD", "INR", "latest")
self.assertIsNone(rate)
@patch("crm.api.exchange_rate.requests.get")
def test_returns_none_on_missing_currency_key(self, mock_get):
# response is ok but to_currency key is absent — should not raise KeyError
mock_get.return_value = _make_response(True, {"rates": {}})
rate = _fetch_from_frankfurter("USD", "INR", "latest")
self.assertIsNone(rate)
@patch(
"crm.api.exchange_rate.requests.get",
side_effect=Exception("Connection refused"),
)
def test_returns_none_on_network_error(self, mock_get):
rate = _fetch_from_frankfurter("USD", "INR", "latest")
self.assertIsNone(rate)
class TestFetchFromFawazApi(FrappeTestCase):
@patch("crm.api.exchange_rate.requests.get")
def test_returns_rate_on_success(self, mock_get):
mock_get.return_value = _make_response(True, {"usd": {"inr": 83.5}})
rate = _fetch_from_fawaz_api("USD", "INR", "latest")
self.assertEqual(rate, 83.5)
@patch("crm.api.exchange_rate.requests.get")
def test_falls_back_to_second_url_when_first_fails(self, mock_get):
# First URL raises, second URL succeeds
mock_get.side_effect = [
Exception("CDN timeout"),
_make_response(True, {"usd": {"inr": 83.5}}),
]
rate = _fetch_from_fawaz_api("USD", "INR", "latest")
self.assertEqual(rate, 83.5)
self.assertEqual(mock_get.call_count, 2)
@patch(
"crm.api.exchange_rate.requests.get",
side_effect=Exception("Network error"),
)
def test_returns_none_when_all_urls_fail(self, mock_get):
rate = _fetch_from_fawaz_api("USD", "INR", "latest")
self.assertIsNone(rate)
class TestFetchFromExchangeRateHost(FrappeTestCase):
@patch("crm.api.exchange_rate.requests.get")
def test_returns_rate_on_success(self, mock_get):
mock_get.return_value = _make_response(True, {"result": 83.5})
settings = _mock_settings("exchangerate.host", access_key="test_key")
rate = _fetch_from_exchangerate_host(settings, "USD", "INR", "latest")
self.assertEqual(rate, 83.5)
def test_raises_when_access_key_missing(self):
settings = _mock_settings("exchangerate.host", access_key="")
with self.assertRaises(frappe.exceptions.ValidationError):
_fetch_from_exchangerate_host(settings, "USD", "INR", "latest")
@patch("crm.api.exchange_rate.requests.get")
def test_returns_none_when_response_not_ok(self, mock_get):
mock_get.return_value = _make_response(False, {})
settings = _mock_settings("exchangerate.host", access_key="test_key")
rate = _fetch_from_exchangerate_host(settings, "USD", "INR", "latest")
self.assertIsNone(rate)
class TestFetchFromExchangeRateApi(FrappeTestCase):
@patch("crm.api.exchange_rate.requests.get")
def test_returns_rate_on_success(self, mock_get):
mock_get.return_value = _make_response(True, {"result": "success", "conversion_rate": 83.5})
settings = _mock_settings("exchangerate-api", access_key="test_key")
rate = _fetch_from_exchangerate_api(settings, "USD", "INR")
self.assertEqual(rate, 83.5)
def test_raises_when_access_key_missing(self):
settings = _mock_settings("exchangerate-api", access_key="")
with self.assertRaises(frappe.exceptions.ValidationError):
_fetch_from_exchangerate_api(settings, "USD", "INR")
@patch("crm.api.exchange_rate.requests.get")
def test_returns_none_when_api_result_is_not_success(self, mock_get):
mock_get.return_value = _make_response(True, {"result": "error", "error-type": "invalid-key"})
settings = _mock_settings("exchangerate-api", access_key="bad_key")
rate = _fetch_from_exchangerate_api(settings, "USD", "INR")
self.assertIsNone(rate)
class TestGetExchangeRate(FrappeTestCase):
def setUp(self):
frappe.cache().delete_value("exchange_rate_USD_INR_latest")
frappe.cache().delete_value(f"exchange_rate_USD_INR_{frappe.utils.today()}")
@patch("crm.api.exchange_rate._fetch_exchange_rate")
def test_returns_rate_on_success(self, mock_fetch):
mock_fetch.return_value = (83.5, "frankfurter")
rate = get_exchange_rate("USD", "INR")
self.assertEqual(rate, 83.5)
@patch("crm.api.exchange_rate._fetch_exchange_rate")
def test_caches_result_and_skips_second_fetch(self, mock_fetch):
mock_fetch.return_value = (83.5, "frankfurter")
get_exchange_rate("USD", "INR")
get_exchange_rate("USD", "INR")
# _fetch_exchange_rate should only be called once; second call hits cache
mock_fetch.assert_called_once()
@patch("crm.api.exchange_rate._fetch_exchange_rate")
def test_raises_when_all_providers_fail(self, mock_fetch):
mock_fetch.return_value = (None, "frankfurter")
with self.assertRaises(frappe.exceptions.ValidationError):
get_exchange_rate("USD", "XYZ")

405
crm/tests/test_form_api.py Normal file
View File

@@ -0,0 +1,405 @@
# 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"})

View File

@@ -0,0 +1,660 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from frappe.tests import IntegrationTestCase
from crm.integrations.api import (
add_note_to_call_log,
add_task_to_call_log,
get_contact_by_phone_number,
get_contact_lead_or_deal_from_number,
get_user_default_calling_medium,
is_call_integration_enabled,
set_default_calling_medium,
)
from crm.integrations.exotel.handler import (
EXOTEL_CALL_STATUSES,
get_call_log_status,
handle_request,
normalize_call_status,
)
class TestIntegrations(IntegrationTestCase):
def tearDown(self):
frappe.db.rollback()
def test_is_call_integration_enabled_both_disabled(self):
"""Test is_call_integration_enabled when both integrations are disabled"""
# Ensure both are disabled
frappe.db.set_single_value("CRM Twilio Settings", "enabled", 0)
frappe.db.set_single_value("CRM Exotel Settings", "enabled", 0)
result = is_call_integration_enabled()
self.assertFalse(result["integrations"]["twilio"])
self.assertFalse(result["integrations"]["exotel"])
def test_is_call_integration_enabled_twilio_only(self):
"""Test is_call_integration_enabled with only Twilio enabled"""
# Enable Twilio settings
frappe.db.set_single_value("CRM Twilio Settings", "enabled", 1)
frappe.db.set_single_value("CRM Exotel Settings", "enabled", 0)
result = is_call_integration_enabled()
self.assertTrue(result["integrations"]["twilio"])
self.assertFalse(result["integrations"]["exotel"])
def test_is_call_integration_enabled_exotel_only(self):
"""Test is_call_integration_enabled with only Exotel enabled"""
# Enable Exotel settings
frappe.db.set_single_value("CRM Exotel Settings", "enabled", 1)
frappe.db.set_single_value("CRM Twilio Settings", "enabled", 0)
result = is_call_integration_enabled()
self.assertFalse(result["integrations"]["twilio"])
self.assertTrue(result["integrations"]["exotel"])
def test_get_user_default_calling_medium_no_agent(self):
"""Test get_user_default_calling_medium when user has no telephony agent record"""
# Delete any existing telephony agent for current user
if frappe.db.exists("CRM Telephony Agent", frappe.session.user):
frappe.delete_doc("CRM Telephony Agent", frappe.session.user)
result = get_user_default_calling_medium()
self.assertIsNone(result)
def test_get_user_default_calling_medium_with_agent(self):
"""Test get_user_default_calling_medium when user has configured medium"""
# Create telephony agent with default medium
if frappe.db.exists("CRM Telephony Agent", frappe.session.user):
frappe.delete_doc("CRM Telephony Agent", frappe.session.user)
frappe.get_doc(
{
"doctype": "CRM Telephony Agent",
"user": frappe.session.user,
"default_medium": "Twilio",
}
).insert()
result = get_user_default_calling_medium()
self.assertEqual(result, "Twilio")
def test_set_default_calling_medium_creates_new_record(self):
"""Test set_default_calling_medium creates new telephony agent if doesn't exist"""
# Delete any existing telephony agent
if frappe.db.exists("CRM Telephony Agent", frappe.session.user):
frappe.delete_doc("CRM Telephony Agent", frappe.session.user)
result = set_default_calling_medium("Exotel")
self.assertEqual(result, "Exotel")
self.assertTrue(frappe.db.exists("CRM Telephony Agent", frappe.session.user))
# Verify the record was created correctly
agent = frappe.get_doc("CRM Telephony Agent", frappe.session.user)
self.assertEqual(agent.default_medium, "Exotel")
def test_set_default_calling_medium_updates_existing_record(self):
"""Test set_default_calling_medium updates existing telephony agent"""
# Create initial telephony agent
if frappe.db.exists("CRM Telephony Agent", frappe.session.user):
frappe.delete_doc("CRM Telephony Agent", frappe.session.user)
frappe.get_doc(
{
"doctype": "CRM Telephony Agent",
"user": frappe.session.user,
"default_medium": "Twilio",
}
).insert()
# Update to Exotel
result = set_default_calling_medium("Exotel")
self.assertEqual(result, "Exotel")
# Verify the record was updated
agent = frappe.get_doc("CRM Telephony Agent", frappe.session.user)
self.assertEqual(agent.default_medium, "Exotel")
def test_add_note_to_call_log_creates_new_note(self):
"""Test add_note_to_call_log creates new note and links it to call log"""
# Create a test call log
call_log = create_test_call_log()
note_data = {
"title": "Call Summary",
"content": "Discussed project requirements and timeline",
}
result = add_note_to_call_log(call_log.name, note_data)
# Verify note was created
self.assertTrue(frappe.db.exists("FCRM Note", result.name))
self.assertEqual(result.title, "Call Summary")
self.assertEqual(result.content, "Discussed project requirements and timeline")
# Verify note is linked to call log
call_log.reload()
linked_notes = [link.link_name for link in call_log.links if link.link_doctype == "FCRM Note"]
self.assertIn(result.name, linked_notes)
def test_add_note_to_call_log_updates_existing_note(self):
"""Test add_note_to_call_log updates existing note content"""
# Create a test call log and initial note
call_log = create_test_call_log()
note = frappe.get_doc(
{
"doctype": "FCRM Note",
"title": "Initial Note",
"content": "Initial content",
}
).insert()
# Update the note
note_data = {
"name": note.name,
"title": "Initial Note",
"content": "Updated content with more details",
}
add_note_to_call_log(call_log.name, note_data)
# Verify content was updated
note.reload()
self.assertEqual(note.content, "Updated content with more details")
def test_add_task_to_call_log_creates_new_task(self):
"""Test add_task_to_call_log creates new task and links it to call log"""
# Create a test call log
call_log = create_test_call_log()
task_data = {
"title": "Follow up call",
"description": "Call customer next week",
"assigned_to": "Administrator",
"due_date": "2026-12-31",
"status": "Todo",
"priority": "High",
}
result = add_task_to_call_log(call_log.name, task_data)
# Verify task was created
self.assertTrue(frappe.db.exists("CRM Task", result.name))
self.assertEqual(result.title, "Follow up call")
self.assertEqual(result.description, "Call customer next week")
self.assertEqual(result.assigned_to, "Administrator")
self.assertEqual(result.status, "Todo")
self.assertEqual(result.priority, "High")
# Verify task is linked to call log
call_log.reload()
linked_tasks = [link.link_name for link in call_log.links if link.link_doctype == "CRM Task"]
self.assertIn(str(result.name), linked_tasks)
def test_add_task_to_call_log_updates_existing_task(self):
"""Test add_task_to_call_log updates existing task"""
# Create a test call log and initial task
call_log = create_test_call_log()
task = frappe.get_doc(
{
"doctype": "CRM Task",
"title": "Initial Task",
"status": "Todo",
"priority": "Medium",
}
).insert()
# Update the task
task_data = {
"name": task.name,
"title": "Updated Task Title",
"description": "Updated description",
"assigned_to": "Administrator",
"due_date": "2026-12-31",
"status": "In Progress",
"priority": "High",
}
result = add_task_to_call_log(call_log.name, task_data)
# Verify task was updated
self.assertEqual(result.name, task.name)
task.reload()
self.assertEqual(task.title, "Updated Task Title")
self.assertEqual(task.description, "Updated description")
self.assertEqual(task.status, "In Progress")
self.assertEqual(task.priority, "High")
def test_get_contact_by_phone_number_finds_contact(self):
"""Test get_contact_by_phone_number finds existing contact"""
# Create a test contact - use exact match by storing what will be searched
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "John",
"last_name": "Doe",
"mobile_no": "4155550100",
}
).insert()
result = get_contact_by_phone_number("4155550100")
# Should find contact even if validation fails
self.assertIn("mobile_no", result)
if "name" in result:
self.assertEqual(result["name"], contact.name)
self.assertEqual(result["full_name"], "John Doe")
def test_get_contact_by_phone_number_finds_contact_with_formatting(self):
"""Test get_contact_by_phone_number handles phone number formatting"""
# Create a test contact
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "Jane",
"last_name": "Smith",
"mobile_no": "4155550200",
}
).insert()
# Search with same number
result = get_contact_by_phone_number("4155550200")
# Should find the contact
self.assertIn("mobile_no", result)
if "name" in result:
self.assertEqual(result["name"], contact.name)
def test_get_contact_by_phone_number_finds_lead(self):
"""Test get_contact_by_phone_number finds lead when no contact exists"""
# Create a test lead with valid Indian phone number
lead = frappe.get_doc(
{
"doctype": "CRM Lead",
"first_name": "Lead",
"last_name": "User",
"mobile_no": "+91 98765 43210",
"lead_owner": "Administrator",
}
).insert()
result = get_contact_by_phone_number("+91 98765 43210")
self.assertEqual(result["name"], lead.name)
self.assertEqual(result["full_name"], "Lead User")
self.assertEqual(result["lead"], lead.name)
def test_get_contact_by_phone_number_prioritizes_contact_with_deal(self):
"""Test get_contact_by_phone_number prioritizes contacts linked to deals"""
# Create organization for deal
org = frappe.get_doc(
{
"doctype": "CRM Organization",
"organization_name": "Test Org",
}
).insert()
# Create contact
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "Contact",
"last_name": "WithDeal",
"mobile_no": "4155550300",
}
).insert()
# Create deal with contact as primary
deal = frappe.get_doc(
{
"doctype": "CRM Deal",
"organization": org.name,
"deal_owner": "Administrator",
}
)
deal.append("contacts", {"contact": contact.name, "is_primary": 1})
deal.insert()
result = get_contact_by_phone_number("4155550300")
# Should return contact - may or may not include deal depending on validation
self.assertIn("mobile_no", result)
if "name" in result:
self.assertEqual(result["name"], contact.name)
if "deal" in result:
self.assertEqual(result["deal"], deal.name)
def test_get_contact_by_phone_number_returns_phone_only_if_not_found(self):
"""Test get_contact_by_phone_number returns phone number if no match found"""
result = get_contact_by_phone_number("+9999999999")
# Should return dict with just mobile_no
self.assertEqual(result["mobile_no"], "+9999999999")
self.assertNotIn("name", result)
self.assertNotIn("full_name", result)
def test_get_contact_by_phone_number_skips_converted_leads(self):
"""Test get_contact_by_phone_number doesn't return converted leads"""
# Create a lead and mark as converted
frappe.get_doc(
{
"doctype": "CRM Lead",
"first_name": "Converted",
"last_name": "Lead",
"mobile_no": "+91 98765 43211",
"lead_owner": "Administrator",
"converted": 1,
}
).insert()
result = get_contact_by_phone_number("+91 98765 43211")
# Should not find the converted lead - should return just the phone number
self.assertNotIn("lead", result)
def test_integration_workflow_call_with_note_and_task(self):
"""Test complete workflow: call log with note and task"""
# Create call log
call_log = create_test_call_log()
# Add note
note_data = {
"title": "Call Notes",
"content": "Customer interested in product demo",
}
note = add_note_to_call_log(call_log.name, note_data)
# Add task
task_data = {
"title": "Schedule demo",
"description": "Set up product demo for next week",
"assigned_to": "Administrator",
"status": "Todo",
"priority": "High",
}
task = add_task_to_call_log(call_log.name, task_data)
# Verify call log has both links
call_log.reload()
link_types = {link.link_doctype for link in call_log.links}
self.assertIn("FCRM Note", link_types)
self.assertIn("CRM Task", link_types)
# Verify we can retrieve both
note_links = [link for link in call_log.links if link.link_doctype == "FCRM Note"]
task_links = [link for link in call_log.links if link.link_doctype == "CRM Task"]
self.assertEqual(len(note_links), 1)
self.assertEqual(len(task_links), 1)
self.assertEqual(note_links[0].link_name, note.name)
self.assertEqual(task_links[0].link_name, str(task.name))
def test_get_contact_lead_or_deal_from_number_returns_contact(self):
"""Test get_contact_lead_or_deal_from_number returns contact when no lead/deal"""
# Create a standalone contact
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "Standalone",
"last_name": "Contact",
"mobile_no": "4155550400",
}
).insert()
docname, doctype = get_contact_lead_or_deal_from_number("4155550400")
# Should return contact
if docname:
self.assertEqual(doctype, "Contact")
self.assertEqual(docname, contact.name)
def test_get_contact_lead_or_deal_from_number_returns_lead(self):
"""Test get_contact_lead_or_deal_from_number prioritizes lead over contact"""
# Create a lead
lead = frappe.get_doc(
{
"doctype": "CRM Lead",
"first_name": "Test",
"last_name": "Lead",
"mobile_no": "+91 98765 43212",
"lead_owner": "Administrator",
}
).insert()
docname, doctype = get_contact_lead_or_deal_from_number("+91 98765 43212")
# Should return lead
if docname:
self.assertEqual(doctype, "CRM Lead")
self.assertEqual(docname, lead.name)
def test_get_contact_lead_or_deal_from_number_returns_deal(self):
"""Test get_contact_lead_or_deal_from_number prioritizes deal over contact"""
# Create organization and contact with deal
org = frappe.get_doc(
{
"doctype": "CRM Organization",
"organization_name": "Deal Test Org",
}
).insert()
contact = frappe.get_doc(
{
"doctype": "Contact",
"first_name": "Deal",
"last_name": "Contact",
"mobile_no": "4155550500",
}
).insert()
deal = frappe.get_doc(
{
"doctype": "CRM Deal",
"organization": org.name,
"deal_owner": "Administrator",
}
)
deal.append("contacts", {"contact": contact.name, "is_primary": 1})
deal.insert()
docname, doctype = get_contact_lead_or_deal_from_number("4155550500")
# Should return deal (prioritized over contact)
if docname:
self.assertEqual(doctype, "CRM Deal")
self.assertEqual(docname, deal.name)
def test_get_contact_lead_or_deal_from_number_returns_none_when_not_found(self):
"""Test get_contact_lead_or_deal_from_number returns None when nothing found"""
docname, doctype = get_contact_lead_or_deal_from_number("+1 999-999-9999")
# Should return None, None
self.assertIsNone(docname)
self.assertIsNone(doctype)
def test_get_contact_lead_or_deal_from_number_ignores_converted_leads(self):
"""Test get_contact_lead_or_deal_from_number doesn't return converted leads"""
# Create a converted lead
frappe.get_doc(
{
"doctype": "CRM Lead",
"first_name": "Converted",
"last_name": "Lead",
"mobile_no": "+91 98765 43213",
"lead_owner": "Administrator",
"converted": 1,
}
).insert()
docname, doctype = get_contact_lead_or_deal_from_number("+91 98765 43213")
# Should return None since lead is converted
self.assertIsNone(docname)
self.assertIsNone(doctype)
class TestExotelCallStatus(IntegrationTestCase):
"""Exotel reports status in its own vocabulary, CRM Call Log status is a Select."""
def get_status_options(self):
return frappe.get_meta("CRM Call Log").get_field("status").options.split("\n")
def test_every_mapped_status_is_a_valid_option(self):
"""Whatever we map to has to exist in the Select, or the call log won't save"""
options = self.get_status_options()
for status in EXOTEL_CALL_STATUSES.values():
self.assertIn(status, options)
def test_normalize_call_status_handles_exotel_casing(self):
self.assertEqual(normalize_call_status("no-answer"), "No Answer")
self.assertEqual(normalize_call_status("in-progress"), "In Progress")
self.assertEqual(normalize_call_status("No_Answer"), "No Answer")
self.assertEqual(normalize_call_status(" Completed "), "Completed")
self.assertEqual(normalize_call_status("cancelled"), "Canceled")
def test_normalize_call_status_treats_empty_values_as_unset(self):
"""A leg that never connected has no status, rather than an unknown one"""
for status in (None, "", "null", "NULL", "undefined"):
self.assertIsNone(normalize_call_status(status))
def test_normalize_call_status_logs_unknown_status(self):
"""An unknown status means the mapping is stale, so leave a trace instead of guessing"""
frappe.db.delete("Error Log", {"method": "Unknown Exotel call status"})
self.assertIsNone(normalize_call_status("some-new-exotel-status"))
error_logs = frappe.get_all(
"Error Log", filters={"method": "Unknown Exotel call status"}, pluck="error"
)
self.assertEqual(len(error_logs), 1)
self.assertIn("some-new-exotel-status", error_logs[0])
def test_incoming_no_answer_without_matching_call_type(self):
"""The status reported in #824, which used to be passed through as "no-answer" """
status = get_call_log_status({"CallType": "client-hangup", "DialCallStatus": "no-answer"})
self.assertEqual(status, "No Answer")
status = get_call_log_status({"Status": "no-answer"})
self.assertEqual(status, "No Answer")
def test_incoming_null_status_is_unset(self):
"""Caller hung up before any agent leg connected"""
status = get_call_log_status({"CallType": "client-hangup", "DialCallStatus": "null"})
self.assertIsNone(status)
def test_first_event_without_status_still_creates_call_log(self):
"""Status is mandatory on the call log, but a status-less first event must not be dropped.
Frappe fills an unset Select with its first option on insert, so the log
comes out as "Initiated" rather than failing the mandatory check.
"""
call_sid = "test-exotel-no-status"
frappe.db.delete("CRM Call Log", {"id": call_sid})
frappe.db.set_single_value("CRM Exotel Settings", "enabled", 1)
frappe.db.set_single_value("CRM Exotel Settings", "webhook_verify_token", "test-token")
self.addCleanup(frappe.db.delete, "CRM Call Log", {"id": call_sid})
request = frappe._dict(args={"key": "test-token"}, headers={})
with patch.object(frappe.local, "request", request, create=True):
handle_request(
CallSid=call_sid,
Direction="incoming",
CallFrom="+0987654321",
To="+1234567890",
DialWhomNumber="+1234567890",
CallType="client-hangup",
DialCallStatus="null",
)
self.assertEqual(frappe.db.get_value("CRM Call Log", {"id": call_sid}, "status"), "Initiated")
def test_incoming_call_type_overrides_are_preserved(self):
cases = [
({"CallType": "incomplete", "DialCallStatus": "no-answer"}, "No Answer"),
({"CallType": "incomplete", "DialCallStatus": "failed"}, "Failed"),
({"CallType": "client-hangup", "DialCallStatus": "canceled"}, "Canceled"),
# a completed call is completed regardless of what the leg reported
({"CallType": "completed", "DialCallStatus": "busy"}, "Completed"),
# busy means it is still ringing on another leg
({"CallType": "incomplete", "DialCallStatus": "busy"}, "Ringing"),
]
for payload, expected in cases:
with self.subTest(payload=payload):
self.assertEqual(get_call_log_status(payload), expected)
def test_dial_call_status_takes_precedence_over_status(self):
payload = {"DialCallStatus": "completed", "Status": "no-answer"}
self.assertEqual(get_call_log_status(payload), "Completed")
def test_outgoing_statuses_are_mapped(self):
cases = [
("completed", "Completed"),
("in-progress", "In Progress"),
("no-answer", "No Answer"),
("failed", "Failed"),
# these used to fall through and be passed on as-is
("canceled", "Canceled"),
("queued", "Queued"),
("ringing", "Ringing"),
("initiated", "Initiated"),
# busy means it is still ringing on another leg
("busy", "Ringing"),
]
for direction in ("outbound-api", "outbound-dial"):
for reported, expected in cases:
with self.subTest(direction=direction, status=reported):
status = get_call_log_status({"Status": reported}, direction=direction)
self.assertEqual(status, expected)
def test_every_status_is_valid_or_unset(self):
"""No payload should ever produce a status the Select would reject"""
options = self.get_status_options()
payloads = [
{},
{"Status": "null"},
{"Status": "some-new-exotel-status"},
{"CallType": "client-hangup", "DialCallStatus": "null"},
]
for reported in EXOTEL_CALL_STATUSES:
payloads.append({"Status": reported})
payloads.append({"CallType": "incomplete", "DialCallStatus": reported})
for direction in ("inbound", "outbound-api", "outbound-dial"):
for payload in payloads:
with self.subTest(direction=direction, payload=payload):
status = get_call_log_status(payload, direction=direction)
if status is not None:
self.assertIn(status, options)
def create_test_call_log(**kwargs):
"""Helper function to create a CRM Call Log for testing"""
import uuid
unique_id = kwargs.pop("id", str(uuid.uuid4())[:10])
data = {
"doctype": "CRM Call Log",
"id": unique_id,
"type": "Incoming",
"status": "Completed",
"to": "+1234567890",
"from": "+0987654321",
}
data.update(kwargs)
call_log = frappe.get_doc(data)
call_log.insert()
return call_log

View File

@@ -0,0 +1,54 @@
import json
import frappe
from frappe.tests import IntegrationTestCase
from crm.api.doc import get_data
def contact_list(**kwargs):
return get_data(
doctype="Contact",
filters={},
order_by="modified desc",
page_length=1,
view={"view_type": "list"},
**kwargs,
)
class TestListViewColumns(IntegrationTestCase):
def setUp(self):
frappe.set_user("Administrator")
frappe.db.delete("CRM View Settings", {"dt": "Contact", "user": "Administrator"})
def tearDown(self):
frappe.db.rollback()
def test_default_columns_keep_hidden_fields_chosen_by_controller(self):
"""Contact.full_name is hidden in the framework schema, but the CRM
controller picks it as the Name column so it must stay."""
self.assertTrue(frappe.get_meta("Contact").get_field("full_name").hidden)
result = contact_list()
keys = [column["key"] for column in result["columns"]]
self.assertTrue(result["is_default"])
self.assertEqual(keys[0], "full_name")
self.assertEqual(keys, ["full_name", "email_id", "mobile_no", "company_name", "modified"])
def test_custom_columns_drop_hidden_fields_not_in_defaults(self):
"""A hidden field the controller did not choose is still removed, and the
column right after it is still processed."""
columns = json.dumps(
[
{"label": "Image", "type": "Attach Image", "key": "image", "width": "8rem"},
{"label": "Email", "type": "Data", "key": "email_id", "width": "12rem"},
]
)
result = contact_list(columns=columns, rows=json.dumps(["name"]))
keys = [column["key"] for column in result["columns"]]
self.assertEqual(keys, ["email_id"])
self.assertIn("email_id", result["rows"])

View File

@@ -0,0 +1,59 @@
from unittest.mock import patch
import frappe
from frappe.tests import IntegrationTestCase
from crm.extends.notification_log import before_insert, get_crm_route
class TestNotificationLogLink(IntegrationTestCase):
def test_get_crm_route_lead(self):
self.assertEqual(get_crm_route("CRM Lead", "CRM-LEAD-0001"), "/crm/leads/CRM-LEAD-0001")
def test_get_crm_route_deal(self):
self.assertEqual(get_crm_route("CRM Deal", "CRM-DEAL-0001"), "/crm/deals/CRM-DEAL-0001")
def test_get_crm_route_non_crm_doctype(self):
"""Non-CRM documents get no CRM route (email keeps the Desk fallback)."""
self.assertIsNone(get_crm_route("ToDo", "abc123"))
def test_get_crm_route_missing_name(self):
self.assertIsNone(get_crm_route("CRM Lead", None))
def test_get_crm_route_task_links_to_parent(self):
"""Tasks have no standalone page; link to the parent Lead/Deal Tasks tab."""
parent = frappe._dict(reference_doctype="CRM Deal", reference_docname="CRM-DEAL-0009")
with patch("frappe.db.get_value", return_value=parent):
self.assertEqual(get_crm_route("CRM Task", "task-1"), "/crm/deals/CRM-DEAL-0009#tasks")
def test_get_crm_route_task_without_parent(self):
with patch("frappe.db.get_value", return_value=None):
self.assertIsNone(get_crm_route("CRM Task", "task-orphan"))
def test_before_insert_sets_crm_link(self):
doc = frappe._dict(link=None, document_type="CRM Lead", document_name="CRM-LEAD-0002")
before_insert(doc)
self.assertEqual(doc.link, frappe.utils.get_url("/crm/leads/CRM-LEAD-0002"))
def test_before_insert_preserves_existing_link(self):
doc = frappe._dict(link="/custom/link", document_type="CRM Lead", document_name="CRM-LEAD-0003")
before_insert(doc)
self.assertEqual(doc.link, "/custom/link")
def test_before_insert_ignores_non_crm_doc(self):
doc = frappe._dict(link=None, document_type="ToDo", document_name="abc")
before_insert(doc)
self.assertIsNone(doc.link)
def test_before_insert_is_type_agnostic(self):
"""Covers assign AND unassign: both emit type='Assignment' logs, but the link is
set purely from the CRM document_type, so any notification type is handled."""
for notification_type in ("Assignment", "Share", "Default", None):
doc = frappe._dict(
link=None,
type=notification_type,
document_type="CRM Deal",
document_name="CRM-DEAL-0004",
)
before_insert(doc)
self.assertEqual(doc.link, frappe.utils.get_url("/crm/deals/CRM-DEAL-0004"))

View File

@@ -0,0 +1,112 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests import IntegrationTestCase
from frappe.utils import sha256_hash
from frappe.utils.password import update_password
from crm.api.user import has_password, needs_password_setup
from crm.www.crm import get_context, redirect_to_set_password
STRONG_PASSWORD = "Qx7#mLp2vT9!"
def make_user(email, roles=None):
"""A user created the way Frappe Cloud and scripts create one: no password."""
user = frappe.get_doc(
doctype="User",
user_type="System User",
email=email,
first_name=email.split("@")[0].title(),
send_welcome_email=0,
).insert(ignore_permissions=True)
if roles:
user.append_roles(*roles)
user.save(ignore_permissions=True)
return user
class TestPasswordSetup(IntegrationTestCase):
def setUp(self):
self.user = make_user("no-password@example.com", roles=["System Manager"])
frappe.set_user(self.user.name)
def tearDown(self):
frappe.set_user("Administrator")
frappe.local.flags.redirect_location = None
frappe.local.flags.commit = False
frappe.local.system_settings = None
frappe.db.rollback()
def make_sso_user(self):
frappe.set_user("Administrator")
self.user.append("social_logins", {"provider": "google", "userid": "1234567890"})
self.user.save(ignore_permissions=True)
frappe.set_user(self.user.name)
# ---- needs_password_setup ----
def test_needs_setup_for_user_without_password(self):
self.assertTrue(needs_password_setup())
def test_does_not_need_setup_once_password_exists(self):
update_password(user=self.user.name, pwd=STRONG_PASSWORD)
self.assertTrue(has_password(self.user.name))
self.assertFalse(needs_password_setup())
def test_does_not_need_setup_for_sso_user(self):
"""No password is the correct state for someone who signs in via SSO."""
self.make_sso_user()
self.assertFalse(needs_password_setup())
def test_does_not_need_setup_when_password_login_is_disabled(self):
# not change_settings: it saves the doc, and validating System Settings
# fails on a bare site that has no language or time zone set
frappe.db.set_single_value("System Settings", "disable_user_pass_login", 1)
frappe.local.system_settings = None
self.assertFalse(needs_password_setup())
# ---- redirect ----
def test_redirects_to_set_password_page_with_a_working_key(self):
with self.assertRaises(frappe.Redirect) as raised:
redirect_to_set_password()
# a 301 would be cached by the browser and outlive the key it points at
self.assertEqual(raised.exception.http_status_code, 302)
# the reset key is written on a GET, which is rolled back otherwise
self.assertTrue(frappe.local.flags.commit)
link = frappe.local.flags.redirect_location
self.assertIn("/update-password?key=", link)
key = link.split("key=")[1]
self.assertEqual(frappe.db.get_value("User", self.user.name, "reset_password_key"), sha256_hash(key))
def test_no_redirect_for_user_who_has_a_password(self):
update_password(user=self.user.name, pwd=STRONG_PASSWORD)
redirect_to_set_password()
self.assertIsNone(frappe.local.flags.redirect_location)
def test_no_redirect_for_sso_user(self):
self.make_sso_user()
redirect_to_set_password()
self.assertIsNone(frappe.local.flags.redirect_location)
def test_crm_page_load_redirects_a_user_without_a_password(self):
"""The redirect is wired into the page the user actually opens."""
with self.assertRaises(frappe.Redirect):
get_context()
self.assertIn("/update-password?key=", frappe.local.flags.redirect_location)

192
crm/tests/test_records.json Normal file
View File

@@ -0,0 +1,192 @@
[
{
"doctype": "User",
"email": "crm.manager@example.com",
"enabled": 1,
"first_name": "Sales",
"last_name": "Manager",
"full_name": "Sales Manager",
"new_password": "CRM_Manager_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales Manager"
},
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.user1@example.com",
"enabled": 1,
"first_name": "John",
"last_name": "Sales",
"full_name": "John Sales",
"new_password": "CRM_User1_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.user2@example.com",
"enabled": 1,
"first_name": "Jane",
"last_name": "Sales",
"full_name": "Jane Sales",
"new_password": "CRM_User2_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.user3@example.com",
"enabled": 1,
"first_name": "Michael",
"last_name": "Sales",
"full_name": "Michael Sales",
"new_password": "CRM_User3_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.user4@example.com",
"enabled": 1,
"first_name": "Sarah",
"last_name": "Sales",
"full_name": "Sarah Sales",
"new_password": "CRM_User4_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.user5@example.com",
"enabled": 1,
"first_name": "David",
"last_name": "Sales",
"full_name": "David Sales",
"new_password": "CRM_User5_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.supervisor@example.com",
"enabled": 1,
"first_name": "Lisa",
"last_name": "Supervisor",
"full_name": "Lisa Supervisor",
"new_password": "CRM_Supervisor_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales Manager"
},
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.rep1@example.com",
"enabled": 1,
"first_name": "Robert",
"last_name": "Representative",
"full_name": "Robert Representative",
"new_password": "CRM_Rep1_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.rep2@example.com",
"enabled": 1,
"first_name": "Emily",
"last_name": "Representative",
"full_name": "Emily Representative",
"new_password": "CRM_Rep2_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
},
{
"doctype": "User",
"email": "crm.admin@example.com",
"enabled": 1,
"first_name": "CRM",
"last_name": "Administrator",
"full_name": "CRM Administrator",
"new_password": "CRM_Admin_2026",
"roles": [
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "System Manager"
},
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales Manager"
},
{
"doctype": "Has Role",
"parentfield": "roles",
"role": "Sales User"
}
],
"user_type": "System User"
}
]

View File

@@ -0,0 +1,85 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import sys
from types import ModuleType
from unittest.mock import patch
import frappe
from frappe.tests.utils import FrappeTestCase
from crm.www.crm import get_boot, get_state_options
def _fake_india_compliance(states: dict) -> dict:
"""Build a fake `india_compliance.gst_india.constants` module tree for sys.modules.
`from india_compliance.gst_india.constants import INDIAN_STATES` imports each parent
package, so all three levels must be present.
"""
root = ModuleType("india_compliance")
gst = ModuleType("india_compliance.gst_india")
constants = ModuleType("india_compliance.gst_india.constants")
constants.INDIAN_STATES = states
return {
"india_compliance": root,
"india_compliance.gst_india": gst,
"india_compliance.gst_india.constants": constants,
}
class TestGetStateOptions(FrappeTestCase):
def test_returns_a_dict(self):
# Never raises; always a dict regardless of what's installed.
self.assertIsInstance(get_state_options(), dict)
def test_empty_without_india_compliance(self):
with patch("crm.www.crm.get_installed_apps", return_value=["frappe", "crm"]):
self.assertEqual(get_state_options(), {})
def test_resilient_when_get_installed_apps_raises(self):
# The v15 boot-context concern: get_installed_apps blowing up must NOT break boot.
with patch("crm.www.crm.get_installed_apps", side_effect=Exception("boot context")):
self.assertEqual(get_state_options(), {})
def test_resilient_when_import_fails(self):
# App reported installed but the constant import fails (e.g. version mismatch).
# Inject a constants module lacking INDIAN_STATES so the import fails deterministically,
# regardless of whether india_compliance is actually importable in this environment.
broken = _fake_india_compliance({})
del broken["india_compliance.gst_india.constants"].INDIAN_STATES
with (
patch("crm.www.crm.get_installed_apps", return_value=["frappe", "crm", "india_compliance"]),
patch.dict(sys.modules, broken),
):
self.assertEqual(get_state_options(), {})
def test_returns_states_when_app_installed(self):
states = {"Goa": "30", "Kerala": "32", "Punjab": "03"}
with (
patch(
"crm.www.crm.get_installed_apps",
return_value=["frappe", "crm", "india_compliance"],
),
patch.dict(sys.modules, _fake_india_compliance(states)),
):
result = get_state_options()
self.assertEqual(list(result.keys()), ["India"])
self.assertEqual(result["India"], ["Goa", "Kerala", "Punjab"])
class TestGetBoot(FrappeTestCase):
def test_boot_includes_state_options_and_does_not_raise(self):
boot = get_boot()
self.assertIn("state_options", boot)
self.assertIsInstance(boot["state_options"], dict)
def test_boot_degrades_when_state_lookup_fails(self):
# The v15 boot-context concern: if the state lookup misbehaves, get_boot must still
# succeed and state_options must be an empty map (never break page load). Patch only
# the binding used by get_state_options — patching frappe.get_installed_apps globally
# would break unrelated earlier boot steps (get_translated_doctypes resolves hooks via
# get_installed_apps), which is what made this test fail in CI.
with patch("crm.www.crm.get_installed_apps", side_effect=Exception("boot context")):
boot = get_boot()
self.assertEqual(boot["state_options"], {})

662
crm/tests/test_utils.py Normal file
View File

@@ -0,0 +1,662 @@
import time
from unittest.mock import patch
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from crm.utils import (
_get_communication_status,
_should_update_modified,
are_same_phone_number,
create_lead_from_incoming_email,
on_communication_update,
parse_phone_number,
seconds_to_duration,
)
class TestUtils(UnitTestCase):
def test_seconds_to_duration(self):
# 3661 seconds = 1 hour, 1 minute, and 1 second
self.assertEqual(seconds_to_duration(3661), "1h 1m 1s")
# 3660 seconds = 1 hour and 1 minute
self.assertEqual(seconds_to_duration(3660), "1h 1m")
# 3601 seconds = 1 hour and 1 second
self.assertEqual(seconds_to_duration(3601), "1h 1s")
# 0 seconds = 0s
self.assertEqual(seconds_to_duration(0), "0s")
def test_seconds_to_duration_single_unit(self):
"""Each single-unit branch (hours / minutes / seconds only) should format correctly."""
self.assertEqual(seconds_to_duration(3600), "1h") # hours only
self.assertEqual(seconds_to_duration(60), "1m") # minutes only
self.assertEqual(seconds_to_duration(1), "1s") # seconds only
def test_seconds_to_duration_minutes_and_seconds(self):
"""Minutes + seconds without hours should format correctly."""
self.assertEqual(seconds_to_duration(61), "1m 1s")
self.assertEqual(seconds_to_duration(90), "1m 30s")
def test_seconds_to_duration_falsy_values(self):
"""None and 0 are both falsy and should return '0s'."""
self.assertEqual(seconds_to_duration(None), "0s")
self.assertEqual(seconds_to_duration(0), "0s")
def test_seconds_to_duration_large_values(self):
"""Values larger than one hour should still compute correctly."""
self.assertEqual(seconds_to_duration(7322), "2h 2m 2s") # 2*3600 + 2*60 + 2
def test_are_same_phone_number_normalized_input(self):
# Indian number with country code and different formats
self.assertTrue(are_same_phone_number("+91 9845552671", "9845552671"))
self.assertTrue(are_same_phone_number("+91-984-555-2671", "9845552671"))
self.assertTrue(are_same_phone_number("+91 (984) 555-2671", "9845552671"))
self.assertTrue(are_same_phone_number("+91 984-555-2671", "9845552671"))
# US number with country code and different formats
self.assertTrue(are_same_phone_number("+1 415 555 2671", "4155552671", default_region="US"))
self.assertTrue(are_same_phone_number("+1-415-555-2671", "4155552671", default_region="US"))
self.assertTrue(are_same_phone_number("+1 (415) 555-2671", "4155552671", default_region="US"))
self.assertTrue(are_same_phone_number("+1 415-555-2671", "4155552671", default_region="US"))
def test_are_same_phone_number_invalid_input(self):
# Invalid numbers should return False
self.assertFalse(
are_same_phone_number("+1 415 555 2671", "4155552671")
) # Missing default region as US
self.assertFalse(
are_same_phone_number("+91 984-555-2671", "9845552671", default_region="US")
) # Wrong default region
self.assertFalse(are_same_phone_number("12345", "67890"))
self.assertFalse(are_same_phone_number("abc", "14155552671"))
def test_are_same_phone_number_different_numbers(self):
"""Two distinct valid numbers should not be considered same."""
self.assertFalse(are_same_phone_number("+91 9845552671", "+91 9845552672"))
self.assertFalse(are_same_phone_number("+1 415 555 2671", "+1 415 555 2672", default_region="US"))
def test_are_same_phone_number_validate_false(self):
"""With validate=False, structurally parseable but invalid numbers can still match."""
# These parse but fail is_valid_number; with validate=False they should still compare equal
self.assertTrue(
are_same_phone_number("+1 415 555 2671", "+1 415 555 2671", default_region="US", validate=False)
)
def test_parse_phone_number_valid_indian_number(self):
"""A valid Indian number should return success=True with correct metadata."""
result = parse_phone_number("+919845552671")
self.assertTrue(result["success"])
self.assertTrue(result["is_valid"])
self.assertEqual(result["country_code"], 91)
self.assertEqual(result["country"], "IN")
self.assertEqual(result["national_number"], "9845552671")
# All four formats must be present
self.assertIn("international", result["formats"])
self.assertIn("national", result["formats"])
self.assertIn("E164", result["formats"])
self.assertIn("RFC3966", result["formats"])
# E164 should start with +91
self.assertTrue(result["formats"]["E164"].startswith("+91"))
def test_parse_phone_number_valid_us_number(self):
"""A valid US number should return success=True with country_code 1."""
result = parse_phone_number("+14155552671")
self.assertTrue(result["success"])
self.assertTrue(result["is_valid"])
self.assertEqual(result["country_code"], 1)
self.assertEqual(result["country"], "US")
def test_parse_phone_number_invalid_string(self):
"""An unparseable string should return success=False with an error key."""
result = parse_phone_number("not-a-number")
self.assertFalse(result["success"])
self.assertIn("error", result)
def test_parse_phone_number_default_country_fallback(self):
"""A local number without a country prefix should be parsed using the default_country."""
result = parse_phone_number("9845552671", default_country="IN")
self.assertTrue(result["success"])
self.assertEqual(result["country"], "IN")
class TestUpdateModifiedTimestamp(IntegrationTestCase):
def setUp(self):
super().setUp()
# Patch frappe.enqueue to run update_modified_background synchronously in tests
self._enqueue_patch = patch("frappe.enqueue", self._immediate_enqueue)
self._enqueue_patch.start()
@staticmethod
def _immediate_enqueue(method, **kwargs):
# Only patch for update_modified_background
from crm.utils import update_modified_background
if method == update_modified_background or (
isinstance(method, str) and method.endswith("update_modified_background")
):
return update_modified_background(kwargs["doctype"], kwargs["docname"])
# fallback: do nothing
return None
@classmethod
def setUpClass(cls):
super().setUpClass()
# Ensure FCRM Settings has the flag enabled; disable both status hooks to isolate
# timestamp behaviour from communication-status side-effects.
frappe.db.set_single_value("FCRM Settings", "update_timestamp_on_new_communication", 1)
frappe.db.set_single_value("FCRM Settings", "auto_reopen_on_new_communication", 0)
frappe.db.set_single_value("FCRM Settings", "auto_mark_replied_on_response", 0)
@classmethod
def tearDownClass(cls):
frappe.db.rollback()
super().tearDownClass()
def tearDown(self):
frappe.db.rollback()
self._enqueue_patch.stop()
super().tearDown()
def _make_lead(self):
lead = frappe.get_doc(
{
"doctype": "CRM Lead",
"first_name": "Test",
"email": "test.ts@example.com",
}
)
lead.insert(ignore_permissions=True)
return lead
def test_timestamp_updated_on_new_communication(self):
"""Inserting a Communication linked to a CRM Lead should bump its modified timestamp."""
lead = self._make_lead()
original_modified = frappe.db.get_value("CRM Lead", lead.name, "modified")
time.sleep(0.1)
comm = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "Hello",
"reference_doctype": "CRM Lead",
"reference_name": lead.name,
}
)
comm.insert(ignore_permissions=True)
updated_modified = frappe.db.get_value("CRM Lead", lead.name, "modified")
self.assertGreater(updated_modified, original_modified)
def test_timestamp_not_updated_when_setting_disabled(self):
"""When the setting is off the timestamp should NOT be updated."""
frappe.db.set_single_value("FCRM Settings", "update_timestamp_on_new_communication", 0)
lead = self._make_lead()
original_modified = frappe.db.get_value("CRM Lead", lead.name, "modified")
time.sleep(0.1)
comm = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "No update expected",
"reference_doctype": "CRM Lead",
"reference_name": lead.name,
}
)
comm.insert(ignore_permissions=True)
updated_modified = frappe.db.get_value("CRM Lead", lead.name, "modified")
self.assertEqual(updated_modified, original_modified)
# Restore
frappe.db.set_single_value("FCRM Settings", "update_timestamp_on_new_communication", 1)
def test_timestamp_updated_on_new_comment(self):
"""Inserting a Comment linked to a CRM Lead should bump its modified timestamp (via hook)."""
lead = self._make_lead()
original_modified = frappe.db.get_value("CRM Lead", lead.name, "modified")
time.sleep(0.1)
comment = frappe.get_doc(
{
"doctype": "Comment",
"comment_type": "Comment",
"reference_doctype": "CRM Lead",
"reference_name": lead.name,
"content": "A test comment",
}
)
comment.insert(ignore_permissions=True)
updated_modified = frappe.db.get_value("CRM Lead", lead.name, "modified")
self.assertGreater(updated_modified, original_modified)
def test_timestamp_not_updated_when_no_reference(self):
"""A Communication without a reference should not touch the DB."""
comm = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "No reference",
}
)
with patch("frappe.db.set_value") as mock_set_value:
# _should_update_modified returns False when no reference — set_value must not be called
result = _should_update_modified(comm)
self.assertFalse(result)
mock_set_value.assert_not_called()
def test_timestamp_direct_call_updates_lead(self):
"""Direct call to on_communication_update updates the reference doc modified timestamp."""
lead = self._make_lead()
time.sleep(0.1)
comm = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "Direct call",
"reference_doctype": "CRM Lead",
"reference_name": lead.name,
}
)
comm.insert(ignore_permissions=True)
before = frappe.db.get_value("CRM Lead", lead.name, "modified")
time.sleep(0.1)
on_communication_update(comm)
after = frappe.db.get_value("CRM Lead", lead.name, "modified")
self.assertGreaterEqual(after, before)
class TestUpdateCommunicationStatus(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.db.set_single_value("FCRM Settings", "auto_reopen_on_new_communication", 1)
frappe.db.set_single_value("FCRM Settings", "auto_mark_replied_on_response", 1)
frappe.db.set_single_value("FCRM Settings", "update_timestamp_on_new_communication", 0)
@classmethod
def tearDownClass(cls):
frappe.db.rollback()
super().tearDownClass()
def tearDown(self):
frappe.db.rollback()
frappe.db.set_single_value("FCRM Settings", "auto_reopen_on_new_communication", 1)
frappe.db.set_single_value("FCRM Settings", "auto_mark_replied_on_response", 1)
def _make_lead(self, suffix=""):
lead = frappe.get_doc(
{
"doctype": "CRM Lead",
"first_name": "CommTest" + suffix,
"email": f"commtest{suffix}@example.com",
}
)
lead.insert(ignore_permissions=True)
return lead
def _insert_communication(self, lead, sent_or_received):
comm = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": sent_or_received,
"subject": f"Test {sent_or_received}",
"reference_doctype": "CRM Lead",
"reference_name": lead.name,
}
)
comm.insert(ignore_permissions=True)
return comm
def test_status_set_to_open_on_received_communication(self):
"""Receiving an incoming communication should set the lead's communication_status to 'Open'."""
lead = self._make_lead("recv")
self._insert_communication(lead, "Received")
status = frappe.db.get_value("CRM Lead", lead.name, "communication_status")
self.assertEqual(status, "Open")
def test_status_set_to_replied_on_sent_communication(self):
"""Sending an outgoing communication should set the lead's communication_status to 'Replied'."""
lead = self._make_lead("sent")
self._insert_communication(lead, "Sent")
status = frappe.db.get_value("CRM Lead", lead.name, "communication_status")
self.assertEqual(status, "Replied")
def test_status_not_updated_when_reopen_setting_disabled(self):
"""When auto_reopen_on_new_communication is off, a Received communication should NOT set status."""
frappe.db.set_single_value("FCRM Settings", "auto_reopen_on_new_communication", 0)
frappe.db.set_single_value("FCRM Settings", "auto_mark_replied_on_response", 0)
lead = self._make_lead("reopen-off")
original_status = frappe.db.get_value("CRM Lead", lead.name, "communication_status")
self._insert_communication(lead, "Received")
status = frappe.db.get_value("CRM Lead", lead.name, "communication_status")
self.assertEqual(status, original_status)
def test_status_not_updated_when_replied_setting_disabled(self):
"""When auto_mark_replied_on_response is off, a Sent communication should NOT set status."""
frappe.db.set_single_value("FCRM Settings", "auto_mark_replied_on_response", 0)
frappe.db.set_single_value("FCRM Settings", "auto_reopen_on_new_communication", 0)
lead = self._make_lead("replied-off")
original_status = frappe.db.get_value("CRM Lead", lead.name, "communication_status")
self._insert_communication(lead, "Sent")
status = frappe.db.get_value("CRM Lead", lead.name, "communication_status")
self.assertEqual(status, original_status)
def test_only_last_communication_drives_status(self):
"""Only the most recent communication should determine the status."""
lead = self._make_lead("last")
self._insert_communication(lead, "Received")
self.assertEqual(frappe.db.get_value("CRM Lead", lead.name, "communication_status"), "Open")
self._insert_communication(lead, "Sent")
self.assertEqual(frappe.db.get_value("CRM Lead", lead.name, "communication_status"), "Replied")
self._insert_communication(lead, "Received")
self.assertEqual(frappe.db.get_value("CRM Lead", lead.name, "communication_status"), "Open")
def test_status_not_updated_when_no_reference(self):
"""A Communication with no reference doctype/name should not touch the DB."""
comm = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "No reference",
}
)
with patch("frappe.db.set_value") as mock_set_value:
on_communication_update(comm)
mock_set_value.assert_not_called()
def test_status_not_updated_for_non_communication_doctype(self):
"""Calling _get_communication_status with a non-Communication doc should return None."""
comment = frappe.get_doc(
{
"doctype": "Comment",
"comment_type": "Comment",
"reference_doctype": "CRM Lead",
"reference_name": "LEAD-0001",
"content": "test",
}
)
self.assertIsNone(_get_communication_status(comment))
def test_status_not_updated_for_unknown_sent_or_received_value(self):
"""A Communication with an unexpected sent_or_received value should be skipped."""
lead = self._make_lead("invalid")
comm = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "Setup",
"reference_doctype": "CRM Lead",
"reference_name": lead.name,
}
)
comm.insert(ignore_permissions=True)
comm.sent_or_received = "Unknown"
self.assertIsNone(_get_communication_status(comm))
status = frappe.db.get_value("CRM Lead", lead.name, "communication_status")
self.assertEqual(status, "Open")
class TestCreateLeadFromIncomingEmail(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.db.set_single_value("FCRM Settings", "auto_reopen_on_new_communication", 0)
frappe.db.set_single_value("FCRM Settings", "auto_mark_replied_on_response", 0)
frappe.db.set_single_value("FCRM Settings", "update_timestamp_on_new_communication", 0)
@classmethod
def tearDownClass(cls):
frappe.db.rollback()
super().tearDownClass()
def tearDown(self):
frappe.db.rollback()
frappe.db.set_single_value("FCRM Settings", "auto_reopen_on_new_communication", 0)
frappe.db.set_single_value("FCRM Settings", "auto_mark_replied_on_response", 0)
frappe.db.set_single_value("FCRM Settings", "update_timestamp_on_new_communication", 0)
def _make_email_account(self, create_lead=1):
"""Create a minimal incoming Email Account with the CRM custom field set."""
email_account = frappe.get_doc(
{
"doctype": "Email Account",
"email_account_name": "Test CRM Incoming",
"email_id": "test-crm-incoming@example.com",
"enable_incoming": 1,
"create_lead_from_incoming_email": create_lead,
}
)
email_account.flags.ignore_mandatory = True
email_account.flags.ignore_validate = True
email_account.insert(ignore_permissions=True)
return email_account
def _incoming_comm(self, sender, email_account_name, sender_full_name=None, **kwargs):
doc = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "Test Incoming Email",
"sender": sender,
"email_account": email_account_name,
**kwargs,
}
)
if sender_full_name:
doc.sender_full_name = sender_full_name
return doc
def test_lead_created_from_incoming_email(self):
"""An unreferenced incoming email should create a new CRM Lead."""
email_account = self._make_email_account()
doc = self._incoming_comm("newlead@example.com", email_account.name, sender_full_name="New Lead")
doc.insert(ignore_permissions=True)
self.assertTrue(frappe.db.exists("CRM Lead", {"email": "newlead@example.com"}))
def test_lead_not_created_when_setting_disabled(self):
"""When create_lead_from_incoming_email is off on the Email Account, no lead is created."""
email_account = self._make_email_account(create_lead=0)
doc = self._incoming_comm("disabled@example.com", email_account.name)
doc.insert(ignore_permissions=True)
self.assertFalse(frappe.db.exists("CRM Lead", {"email": "disabled@example.com"}))
def test_lead_not_created_when_no_email_account(self):
"""A Communication without an email_account should not create a lead."""
doc = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": "No email account",
"sender": "noaccount@example.com",
}
)
with patch("frappe.new_doc") as mock_new_doc:
create_lead_from_incoming_email(doc)
mock_new_doc.assert_not_called()
def test_lead_not_created_when_communication_already_referenced(self):
"""A Communication with an existing reference should not create a new lead."""
email_account = self._make_email_account()
existing = frappe.get_doc(
{"doctype": "CRM Lead", "first_name": "RefLead", "email": "reflead@example.com"}
)
existing.insert(ignore_permissions=True)
doc = self._incoming_comm(
"referenced@example.com",
email_account.name,
reference_doctype="CRM Lead",
reference_name=existing.name,
)
doc.insert(ignore_permissions=True)
self.assertFalse(frappe.db.exists("CRM Lead", {"email": "referenced@example.com"}))
def test_lead_not_created_when_lead_already_exists_for_sender(self):
"""No duplicate lead should be created when a lead with the sender email already exists."""
email_account = self._make_email_account()
existing = frappe.get_doc(
{"doctype": "CRM Lead", "first_name": "Existing", "email": "dupe@example.com"}
)
existing.insert(ignore_permissions=True)
doc = self._incoming_comm("dupe@example.com", email_account.name)
doc.insert(ignore_permissions=True)
self.assertEqual(frappe.db.count("CRM Lead", {"email": "dupe@example.com"}), 1)
def test_lead_not_created_for_non_communication_doctype(self):
"""Calling the function with a non-Communication doc should be a no-op."""
comment = frappe.get_doc(
{
"doctype": "Comment",
"comment_type": "Comment",
"reference_doctype": "CRM Lead",
"reference_name": "LEAD-0001",
"content": "test",
}
)
with patch("frappe.new_doc") as mock_new_doc:
create_lead_from_incoming_email(comment)
mock_new_doc.assert_not_called()
def test_lead_not_created_for_sent_communication_with_non_communication_type(self):
"""A sent message with a non-Communication type should not create a lead."""
doc = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Notification",
"communication_medium": "Email",
"sent_or_received": "Sent",
"subject": "Sent notification",
"sender": "sent@example.com",
}
)
with patch("frappe.new_doc") as mock_new_doc:
create_lead_from_incoming_email(doc)
mock_new_doc.assert_not_called()
def test_lead_first_name_from_sender_full_name(self):
"""The lead's first_name should come from sender_full_name when present."""
email_account = self._make_email_account()
doc = self._incoming_comm("fullname@example.com", email_account.name, sender_full_name="Jane Doe")
create_lead_from_incoming_email(doc)
lead = frappe.db.get_values(
"CRM Lead", {"email": "fullname@example.com"}, ["first_name", "last_name"], as_dict=True
)
self.assertEqual(lead, [{"first_name": "Jane", "last_name": "Doe"}])
def test_lead_first_name_falls_back_to_email_prefix(self):
"""When sender_full_name is absent, the email prefix should be used as first_name."""
email_account = self._make_email_account()
doc = self._incoming_comm("prefix@example.com", email_account.name)
create_lead_from_incoming_email(doc)
lead = frappe.db.get_value("CRM Lead", {"email": "prefix@example.com"}, "first_name")
self.assertEqual(lead, "prefix")
def test_lead_last_name_empty_for_single_word_sender_full_name(self):
"""When sender_full_name is a single word, last_name should be empty."""
email_account = self._make_email_account()
doc = self._incoming_comm("singlename@example.com", email_account.name, sender_full_name="Mononym")
create_lead_from_incoming_email(doc)
lead = frappe.db.get_values(
"CRM Lead", {"email": "singlename@example.com"}, ["first_name", "last_name"], as_dict=True
)
self.assertEqual(lead, [{"first_name": "Mononym", "last_name": ""}])
def test_communication_linked_back_to_created_lead(self):
"""After lead creation, the communication's reference_doctype and reference_name should point to the new lead."""
email_account = self._make_email_account()
doc = self._incoming_comm("linked@example.com", email_account.name, sender_full_name="Link Test")
create_lead_from_incoming_email(doc)
self.assertEqual(doc.reference_doctype, "CRM Lead")
lead_name = frappe.db.get_value("CRM Lead", {"email": "linked@example.com"}, "name")
self.assertEqual(doc.reference_name, lead_name)
def test_lead_source_set_to_email_when_source_exists(self):
"""Lead source should be set to 'Email' when the CRM Lead Source 'Email' exists."""
if not frappe.db.exists("CRM Lead Source", "Email"):
frappe.get_doc({"doctype": "CRM Lead Source", "name": "Email"}).insert(ignore_permissions=True)
email_account = self._make_email_account()
doc = self._incoming_comm("leadsource@example.com", email_account.name)
create_lead_from_incoming_email(doc)
source = frappe.db.get_value("CRM Lead", {"email": "leadsource@example.com"}, "source")
self.assertEqual(source, "Email")
def test_lead_created_for_sent_communication_with_communication_type(self):
"""A sent communication with communication_type='Communication' should still create a lead.
The guard condition uses AND: both sent_or_received != 'Received' AND
communication_type != 'Communication' must be true to bail out. When the
type IS 'Communication', the second condition is false and the function proceeds.
"""
email_account = self._make_email_account()
doc = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Sent",
"subject": "Sent communication type",
"sender": "sentcomm@example.com",
"email_account": email_account.name,
}
)
create_lead_from_incoming_email(doc)
self.assertTrue(frappe.db.exists("CRM Lead", {"email": "sentcomm@example.com"}))

View File

@@ -0,0 +1,91 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import MagicMock, patch
import frappe
from frappe.tests.utils import FrappeTestCase
from crm.api.whatsapp import notify_agent, validate
class TestWhatsAppHooks(FrappeTestCase):
def tearDown(self):
frappe.db.rollback()
# --- validate() ---
def test_validate_sets_reference_when_contact_found(self):
"""validate() links the doc when a matching Contact/Lead is found"""
doc = MagicMock()
doc.type = "Incoming"
doc.get.return_value = "+15551234567"
with patch(
"crm.api.whatsapp.get_contact_lead_or_deal_from_number",
return_value=("LEAD-0001", "CRM Lead"),
):
validate(doc, None)
self.assertEqual(doc.reference_doctype, "CRM Lead")
self.assertEqual(doc.reference_name, "LEAD-0001")
def test_validate_skips_reference_when_no_contact_found(self):
"""validate() leaves reference fields untouched when number is unknown"""
doc = MagicMock()
doc.type = "Incoming"
doc.get.return_value = "+15559999999"
doc.reference_doctype = None
doc.reference_name = None
with patch(
"crm.api.whatsapp.get_contact_lead_or_deal_from_number",
return_value=(None, None),
):
validate(doc, None)
self.assertIsNone(doc.reference_doctype)
self.assertIsNone(doc.reference_name)
def test_validate_logs_error_on_exception(self):
"""validate() catches lookup exceptions and logs them instead of raising"""
doc = MagicMock()
doc.type = "Incoming"
doc.get.return_value = "invalid-number"
with (
patch(
"crm.api.whatsapp.get_contact_lead_or_deal_from_number",
side_effect=Exception("parse error"),
),
patch("frappe.log_error") as mock_log,
):
validate(doc, None) # must not raise
mock_log.assert_called_once()
# --- notify_agent() ---
def test_notify_agent_returns_early_when_no_reference(self):
"""notify_agent() skips notification when reference_doctype and reference_name are absent"""
doc = MagicMock()
doc.type = "Incoming"
doc.reference_doctype = None
doc.reference_name = None
with patch("crm.api.whatsapp.get_assigned_users") as mock_users:
notify_agent(doc) # must not raise
mock_users.assert_not_called()
def test_notify_agent_returns_early_when_reference_doctype_missing(self):
"""notify_agent() skips notification when only reference_doctype is absent"""
doc = MagicMock()
doc.type = "Incoming"
doc.reference_doctype = ""
doc.reference_name = "LEAD-0001"
with patch("crm.api.whatsapp.get_assigned_users") as mock_users:
notify_agent(doc)
mock_users.assert_not_called()