From 14b83b46ac63fc2b18c5eac45958e23fd09b6c5f Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 22 Jun 2026 18:14:38 +0530 Subject: [PATCH 1/3] test(task): cover bulk actions, template deps, and delete guards Covers the whitelisted set_multiple_status and add_multiple_tasks helpers (including the blank-subject skip), the template-task dependency validation, the on_trash child-exists guard, and a child task registering itself in its parent's depends_on. --- erpnext/projects/doctype/task/test_task.py | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/erpnext/projects/doctype/task/test_task.py b/erpnext/projects/doctype/task/test_task.py index 0d02fa9c9fb..66072264c7d 100644 --- a/erpnext/projects/doctype/task/test_task.py +++ b/erpnext/projects/doctype/task/test_task.py @@ -158,6 +158,49 @@ class TestTask(ERPNextTestSuite): task.save() self.assertEqual(getdate(task.exp_end_date), getdate(add_days(nowdate(), 5))) + def test_set_multiple_status(self): + from erpnext.projects.doctype.task.task import set_multiple_status + + task1 = create_task("_Test Bulk Status 1") + task2 = create_task("_Test Bulk Status 2") + + set_multiple_status(frappe.as_json([task1.name, task2.name]), "Completed") + + self.assertEqual(frappe.db.get_value("Task", task1.name, "status"), "Completed") + self.assertEqual(frappe.db.get_value("Task", task2.name, "status"), "Completed") + + def test_add_multiple_tasks_under_parent(self): + from erpnext.projects.doctype.task.task import add_multiple_tasks + + parent = create_task("_Test Bulk Parent", is_group=1) + rows = [{"subject": "_Test Bulk Child A"}, {"subject": ""}, {"subject": "_Test Bulk Child B"}] + + add_multiple_tasks(frappe.as_json(rows), parent.name) + + children = frappe.get_all("Task", filters={"parent_task": parent.name}, pluck="subject") + # the row with a blank subject is skipped + self.assertEqual(sorted(children), ["_Test Bulk Child A", "_Test Bulk Child B"]) + + def test_template_task_dependency_must_be_template(self): + normal_task = create_task("_Test Non Template Dependency") + template_task = create_task("_Test Template With Dependency", is_template=1, save=False) + template_task.append("depends_on", {"task": normal_task.name}) + + self.assertRaises(frappe.ValidationError, template_task.save) + + def test_cannot_delete_task_with_children(self): + parent = create_task("_Test Parent With Child", is_group=1) + create_task("_Test Child Blocking Delete", parent_task=parent.name) + + self.assertRaises(frappe.ValidationError, parent.delete) + + def test_child_task_registers_in_parent_depends_on(self): + parent = create_task("_Test Parent Depends On", is_group=1) + child = create_task("_Test Child Depends On", parent_task=parent.name) + + parent.reload() + self.assertIn(child.name, [row.task for row in parent.depends_on]) + def create_task( subject, From bab97aaad0110aa684f70621af0b0e1fc03f4b5a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 22 Jun 2026 18:14:40 +0530 Subject: [PATCH 2/3] test(timesheet): cover activity cost and billing-rate helpers Covers get_activity_cost falling back to the Activity Type rates (and the empty result for an unknown type), plus get_timesheet_data and get_timesheet_detail_rate for a billable timesheet detail. --- .../doctype/timesheet/test_timesheet.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index 6703a95410b..28ba6cebdef 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -422,6 +422,37 @@ class TestTimesheet(ERPNextTestSuite): self.assertIsNotNone(row, "billed timesheet not returned by portal list") self.assertEqual(row.sales_invoice, si.name) + def test_get_activity_cost_falls_back_to_activity_type(self): + from erpnext.projects.doctype.timesheet.timesheet import get_activity_cost + + update_activity_type("_Test Activity Type") + # no employee-specific Activity Cost row, so the Activity Type rates are used + rate = get_activity_cost(employee=None, activity_type="_Test Activity Type") + self.assertEqual(rate["billing_rate"], 50.0) + self.assertEqual(rate["costing_rate"], 20.0) + + # an unknown activity type yields an empty dict, not an error + self.assertEqual(get_activity_cost(activity_type="__Nonexistent Activity__"), {}) + + def test_billing_helpers_for_timesheet_detail(self): + from erpnext.projects.doctype.timesheet.timesheet import ( + get_timesheet_data, + get_timesheet_detail_rate, + ) + + employee = make_employee("_test_timesheet_billing_helpers@example.com", company="_Test Company") + timesheet = make_timesheet(employee, is_billable=1, simulate=True) + detail = timesheet.time_logs[0] + + # 2 billable hours at a billing rate of 50 + data = get_timesheet_data(timesheet.name, project="") + self.assertEqual(data["billing_hours"], 2) + self.assertEqual(data["billing_amount"], 100) + + # same currency on both sides, so the rate is the raw billing amount + rate = get_timesheet_detail_rate(detail.name, timesheet.currency) + self.assertEqual(rate, detail.billing_amount) + @staticmethod def _delete_if_exists(doctype, name): if frappe.db.exists(doctype, name): From b2bae839acc23947d6a313b890b0702c509ff5de Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Mon, 22 Jun 2026 18:27:00 +0530 Subject: [PATCH 3/3] test(activity-cost): cover default-cost title and duplication Covers the no-employee path (title set to the activity type and the default-cost duplication guard) and employee_name being fetched for the title. Brings activity_cost.py to full coverage. --- .../activity_cost/test_activity_cost.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/erpnext/projects/doctype/activity_cost/test_activity_cost.py b/erpnext/projects/doctype/activity_cost/test_activity_cost.py index 71b8c5e6896..86083cf9813 100644 --- a/erpnext/projects/doctype/activity_cost/test_activity_cost.py +++ b/erpnext/projects/doctype/activity_cost/test_activity_cost.py @@ -27,3 +27,43 @@ class TestActivityCost(ERPNextTestSuite): activity_cost1.insert() activity_cost2 = frappe.copy_doc(activity_cost1) self.assertRaises(DuplicationError, activity_cost2.insert) + + def test_default_activity_cost_title_and_duplication(self): + activity_type = self._activity_type("_Test Default Cost Type") + + default_cost = frappe.get_doc( + { + "doctype": "Activity Cost", + "activity_type": activity_type, + "billing_rate": 80, + "costing_rate": 40, + } + ).insert() + # without an employee, the title is just the activity type + self.assertEqual(default_cost.title, activity_type) + + duplicate = frappe.copy_doc(default_cost) + self.assertRaises(DuplicationError, duplicate.insert) + + def test_employee_name_and_title_are_set(self): + activity_type = self._activity_type("_Test Employee Cost Type") + employee = frappe.db.get_all("Employee", filters={"first_name": "_Test Employee"})[0].name + employee_name = frappe.db.get_value("Employee", employee, "employee_name") + + # employee_name is left blank so set_title has to fetch it + cost = frappe.get_doc( + { + "doctype": "Activity Cost", + "employee": employee, + "activity_type": activity_type, + "billing_rate": 60, + "costing_rate": 30, + } + ).insert() + self.assertEqual(cost.employee_name, employee_name) + self.assertEqual(cost.title, f"{employee_name} for {activity_type}") + + def _activity_type(self, name): + if not frappe.db.exists("Activity Type", name): + frappe.get_doc({"doctype": "Activity Type", "activity_type": name}).insert() + return name