Files
upstream-frappe-crm/crm/tests/test_dashboard.py
upstream-mirror db69de1c11
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
snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:36:01 +05:00

832 lines
32 KiB
Python

# 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())