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
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:
22
crm/tests/__init__.py
Normal file
22
crm/tests/__init__.py
Normal 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)
|
||||
119
crm/tests/test_activity_permlevel.py
Normal file
119
crm/tests/test_activity_permlevel.py
Normal 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
831
crm/tests/test_dashboard.py
Normal 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
129
crm/tests/test_demo_data.py
Normal 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))
|
||||
183
crm/tests/test_exchange_rate.py
Normal file
183
crm/tests/test_exchange_rate.py
Normal 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
405
crm/tests/test_form_api.py
Normal 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"})
|
||||
660
crm/tests/test_integrations.py
Normal file
660
crm/tests/test_integrations.py
Normal 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
|
||||
54
crm/tests/test_list_view_columns.py
Normal file
54
crm/tests/test_list_view_columns.py
Normal 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"])
|
||||
59
crm/tests/test_notification_log.py
Normal file
59
crm/tests/test_notification_log.py
Normal 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"))
|
||||
112
crm/tests/test_password_setup.py
Normal file
112
crm/tests/test_password_setup.py
Normal 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
192
crm/tests/test_records.json
Normal 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"
|
||||
}
|
||||
]
|
||||
85
crm/tests/test_state_options.py
Normal file
85
crm/tests/test_state_options.py
Normal 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
662
crm/tests/test_utils.py
Normal 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"}))
|
||||
91
crm/tests/test_whatsapp.py
Normal file
91
crm/tests/test_whatsapp.py
Normal 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()
|
||||
Reference in New Issue
Block a user