From c0a7ac412e3bf0e9d8b64f8536c5a539ebd7877d Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:32 +0300 Subject: [PATCH 01/74] test: remove the empty Bin Setup stub --- .../doctype/bin_setup/test_bin_setup.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/purchase_and_stock_management/doctype/bin_setup/test_bin_setup.py diff --git a/csf_tz/purchase_and_stock_management/doctype/bin_setup/test_bin_setup.py b/csf_tz/purchase_and_stock_management/doctype/bin_setup/test_bin_setup.py deleted file mode 100644 index e193e4ce..00000000 --- a/csf_tz/purchase_and_stock_management/doctype/bin_setup/test_bin_setup.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestBinSetup(unittest.TestCase): - pass From f00e8ee703196a54887bbe9406f70ee53f9bf9ee Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:33 +0300 Subject: [PATCH 02/74] test: remove the empty Item Number stub --- .../doctype/item_number/test_item_number.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/purchase_and_stock_management/doctype/item_number/test_item_number.py diff --git a/csf_tz/purchase_and_stock_management/doctype/item_number/test_item_number.py b/csf_tz/purchase_and_stock_management/doctype/item_number/test_item_number.py deleted file mode 100644 index 98d09b19..00000000 --- a/csf_tz/purchase_and_stock_management/doctype/item_number/test_item_number.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestItemNumber(unittest.TestCase): - pass From caddf208f85a694721f4ce4600692f4d0f72a618 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:33 +0300 Subject: [PATCH 03/74] test: remove the empty Order Track stub --- .../doctype/order_track/test_order_track.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/purchase_and_stock_management/doctype/order_track/test_order_track.py diff --git a/csf_tz/purchase_and_stock_management/doctype/order_track/test_order_track.py b/csf_tz/purchase_and_stock_management/doctype/order_track/test_order_track.py deleted file mode 100644 index 85f65872..00000000 --- a/csf_tz/purchase_and_stock_management/doctype/order_track/test_order_track.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestOrderTrack(unittest.TestCase): - pass From 55cdd077d4cc81fc99f22f15f2df26d4a66341e1 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:34 +0300 Subject: [PATCH 04/74] test: remove the empty Purchase And Stock Management Test stub --- .../test_purchase_and_stock_management_test.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/purchase_and_stock_management/doctype/purchase_and_stock_management_test/test_purchase_and_stock_management_test.py diff --git a/csf_tz/purchase_and_stock_management/doctype/purchase_and_stock_management_test/test_purchase_and_stock_management_test.py b/csf_tz/purchase_and_stock_management/doctype/purchase_and_stock_management_test/test_purchase_and_stock_management_test.py deleted file mode 100644 index 2c58fd9e..00000000 --- a/csf_tz/purchase_and_stock_management/doctype/purchase_and_stock_management_test/test_purchase_and_stock_management_test.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestPurchaseAndStockManagementTest(unittest.TestCase): - pass From 7cca2810dd6958720e757602f30316967caca3d8 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:34 +0300 Subject: [PATCH 05/74] test: remove the empty Allert Custom stub --- .../doctype/allert_custom/test_allert_custom.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/sales_and_marketing/doctype/allert_custom/test_allert_custom.py diff --git a/csf_tz/sales_and_marketing/doctype/allert_custom/test_allert_custom.py b/csf_tz/sales_and_marketing/doctype/allert_custom/test_allert_custom.py deleted file mode 100644 index 3f5de245..00000000 --- a/csf_tz/sales_and_marketing/doctype/allert_custom/test_allert_custom.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestAllertCustom(unittest.TestCase): - pass From da0f6c3499aea83af8a135d2a769ce64d5728b5e Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:35 +0300 Subject: [PATCH 06/74] test: remove the empty Communications stub --- .../doctype/communications/test_communications.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/sales_and_marketing/doctype/communications/test_communications.py diff --git a/csf_tz/sales_and_marketing/doctype/communications/test_communications.py b/csf_tz/sales_and_marketing/doctype/communications/test_communications.py deleted file mode 100644 index e7e88871..00000000 --- a/csf_tz/sales_and_marketing/doctype/communications/test_communications.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestCommunications(unittest.TestCase): - pass From 023606d2e1989e5b739add232784c2cb4740ba96 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:35 +0300 Subject: [PATCH 07/74] test: remove the empty Marketing Dept stub --- .../doctype/marketing_dept/test_marketing_dept.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/sales_and_marketing/doctype/marketing_dept/test_marketing_dept.py diff --git a/csf_tz/sales_and_marketing/doctype/marketing_dept/test_marketing_dept.py b/csf_tz/sales_and_marketing/doctype/marketing_dept/test_marketing_dept.py deleted file mode 100644 index 1ff18488..00000000 --- a/csf_tz/sales_and_marketing/doctype/marketing_dept/test_marketing_dept.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestMarketingDept(unittest.TestCase): - pass From 0ee8b31805ef71536f28f95e8bbcdbf51a23b545 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:36 +0300 Subject: [PATCH 08/74] test: remove the empty Past Sales stub --- .../doctype/past_sales/test_past_sales.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/sales_and_marketing/doctype/past_sales/test_past_sales.py diff --git a/csf_tz/sales_and_marketing/doctype/past_sales/test_past_sales.py b/csf_tz/sales_and_marketing/doctype/past_sales/test_past_sales.py deleted file mode 100644 index 1683951c..00000000 --- a/csf_tz/sales_and_marketing/doctype/past_sales/test_past_sales.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestPastSales(unittest.TestCase): - pass From 809b37f0126bcccb8c7f5d693c8bdcbbab4cee90 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:37 +0300 Subject: [PATCH 09/74] test: remove the empty Past Serial No stub --- .../doctype/past_serial_no/test_past_serial_no.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csf_tz/sales_and_marketing/doctype/past_serial_no/test_past_serial_no.py diff --git a/csf_tz/sales_and_marketing/doctype/past_serial_no/test_past_serial_no.py b/csf_tz/sales_and_marketing/doctype/past_serial_no/test_past_serial_no.py deleted file mode 100644 index 5b15bb52..00000000 --- a/csf_tz/sales_and_marketing/doctype/past_serial_no/test_past_serial_no.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -import unittest - - -class TestPastSerialNo(unittest.TestCase): - pass From f3c9046f077dcf2921905e32ba289c49a9a26825 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:37 +0300 Subject: [PATCH 10/74] test: add shared fixtures for the custom api tests --- csf_tz/tests/custom_api_helpers.py | 157 +++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 csf_tz/tests/custom_api_helpers.py diff --git a/csf_tz/tests/custom_api_helpers.py b/csf_tz/tests/custom_api_helpers.py new file mode 100644 index 00000000..af2049c5 --- /dev/null +++ b/csf_tz/tests/custom_api_helpers.py @@ -0,0 +1,157 @@ +"""Shared factories for the custom_api integration tests.""" + +from unittest.mock import patch + +import frappe +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from frappe.utils import nowdate + +COMPANY = "_Test Company" +CUSTOMER = "_Test Customer" +SUPPLIER = "_Test Supplier" +WAREHOUSE = "_Test Warehouse - _TC" +COST_CENTER = "_Test Cost Center - _TC" + + +def disable_db_commit(test_case): + """App code commits inside hooks; keep the class rollback effective.""" + patcher = patch.object(frappe.db, "commit") + patcher.start() + test_case.addCleanup(patcher.stop) + + +def disable_db_commit_for_class(test_class): + patcher = patch.object(frappe.db, "commit") + patcher.start() + test_class.addClassCleanup(patcher.stop) + + +def set_csf_settings(**values): + for fieldname, value in values.items(): + frappe.db.set_single_value("CSF TZ Settings", fieldname, value) + + +def make_test_item(item_code, **properties): + properties.setdefault("is_stock_item", 1) + return make_item(item_code, properties) + + +def add_stock(item_code, qty, rate=100, warehouse=WAREHOUSE, **args): + return make_stock_entry( + item_code=item_code, qty=qty, rate=rate, to_warehouse=warehouse, company=COMPANY, **args + ) + + +def make_sales_invoice(**args): + args = frappe._dict(args) + invoice = frappe.new_doc("Sales Invoice") + invoice.update( + { + "company": COMPANY, + "customer": args.customer or CUSTOMER, + "debit_to": "Debtors - _TC", + "posting_date": args.posting_date or nowdate(), + "set_posting_time": 1, + "currency": "INR", + "conversion_rate": 1, + "update_stock": args.update_stock or 0, + "is_pos": args.is_pos or 0, + "is_return": args.is_return or 0, + "return_against": args.return_against, + "set_warehouse": args.set_warehouse, + "is_not_vfd_invoice": 1, + "ignore_pricing_rule": 1, + } + ) + for row in args.rows or [{}]: + invoice.append("items", sales_invoice_row(args, row)) + if args.do_not_save: + return invoice + invoice.insert() + if not args.do_not_submit: + invoice.submit() + return invoice + + +def sales_invoice_row(args, row): + row = frappe._dict(row) + return { + "item_code": row.item_code or args.item_code or "_Test Item", + "qty": row.qty if row.qty is not None else (args.qty if args.qty is not None else 1), + "rate": row.rate if row.rate is not None else (args.rate if args.rate is not None else 100), + "price_list_rate": row.price_list_rate or args.price_list_rate or 0, + "discount_amount": row.discount_amount or args.discount_amount or 0, + "warehouse": row.warehouse or args.warehouse or WAREHOUSE, + "conversion_factor": row.conversion_factor or args.conversion_factor or 1, + "stock_qty": row.stock_qty or 0, + "income_account": "Sales - _TC", + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": COST_CENTER, + "allow_override_net_rate": row.allow_override_net_rate or args.allow_override_net_rate or 0, + } + + +def make_purchase_invoice(**args): + args = frappe._dict(args) + invoice = frappe.new_doc("Purchase Invoice") + invoice.update( + { + "company": COMPANY, + "supplier": SUPPLIER, + "credit_to": "Creditors - _TC", + "posting_date": nowdate(), + "set_posting_time": 1, + "currency": "INR", + "conversion_rate": 1, + "ignore_pricing_rule": 1, + } + ) + invoice.append( + "items", + { + "item_code": args.item_code or "_Test Item", + "qty": args.qty if args.qty is not None else 1, + "rate": args.rate if args.rate is not None else 50, + "warehouse": args.warehouse or WAREHOUSE, + "expense_account": "_Test Account Cost for Goods Sold - _TC", + "cost_center": COST_CENTER, + }, + ) + if args.do_not_save: + return invoice + invoice.insert() + if not args.do_not_submit: + invoice.submit() + return invoice + + +def make_delivery_note(**args): + args = frappe._dict(args) + note = frappe.new_doc("Delivery Note") + note.update( + { + "company": COMPANY, + "customer": args.customer or CUSTOMER, + "posting_date": args.posting_date or nowdate(), + "set_posting_time": 1, + "currency": "INR", + "conversion_rate": 1, + "ignore_pricing_rule": 1, + } + ) + note.append( + "items", + { + "item_code": args.item_code or "_Test Item", + "qty": args.qty if args.qty is not None else 1, + "rate": args.rate if args.rate is not None else 100, + "warehouse": args.warehouse or WAREHOUSE, + "expense_account": "Cost of Goods Sold - _TC", + "cost_center": COST_CENTER, + }, + ) + note.insert() + if not args.do_not_submit: + note.submit() + return note From 52d241138157f0235ff4507fc2f231621e067505 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:45 +0300 Subject: [PATCH 11/74] test: add shared fixtures for the payroll tests --- csf_tz/tests/hr_payroll_fixtures.py | 104 ++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 csf_tz/tests/hr_payroll_fixtures.py diff --git a/csf_tz/tests/hr_payroll_fixtures.py b/csf_tz/tests/hr_payroll_fixtures.py new file mode 100644 index 00000000..f2fd0281 --- /dev/null +++ b/csf_tz/tests/hr_payroll_fixtures.py @@ -0,0 +1,104 @@ +"""Shared fixtures for the csf_tz HR and payroll tests.""" + +import frappe +from erpnext.setup.doctype.employee.test_employee import make_employee +from frappe.tests import IntegrationTestCase +from frappe.utils import get_first_day, nowdate +from hrms.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates +from hrms.payroll.doctype.payroll_entry.test_payroll_entry import get_payroll_entry +from hrms.payroll.doctype.salary_slip.test_salary_slip import ( + make_deduction_salary_component, + make_earning_salary_component, +) +from hrms.payroll.doctype.salary_structure.salary_structure import ( + make_salary_slip as make_salary_slip_from_structure, +) +from hrms.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + +COMPANY = "_Test Company" +PAYROLL_PAYABLE_ACCOUNT = "Payroll Payable - _TC" + + +class HRPayrollTestCase(IntegrationTestCase): + """Rolls each test back to a savepoint so class fixtures survive.""" + + SAVEPOINT = "csf_tz_hr_test" + + def setUp(self): + super().setUp() + frappe.db.savepoint(self.SAVEPOINT) + + def tearDown(self): + frappe.db.rollback(save_point=self.SAVEPOINT) + frappe.clear_document_cache("CSF TZ Settings", "CSF TZ Settings") + super().tearDown() + + +def set_csf_tz_settings(**values): + for field, value in values.items(): + frappe.db.set_single_value("CSF TZ Settings", field, value) + frappe.clear_document_cache("CSF TZ Settings", "CSF TZ Settings") + + +def setup_payroll_master_data(): + make_earning_salary_component(setup=True, company_list=[COMPANY]) + make_deduction_salary_component(setup=True, company_list=[COMPANY]) + frappe.db.set_value("Account", PAYROLL_PAYABLE_ACCOUNT, "account_type", "Payable") + frappe.db.set_value("Company", COMPANY, "default_payroll_payable_account", PAYROLL_PAYABLE_ACCOUNT) + frappe.db.set_single_value("Payroll Settings", "email_salary_slip_to_employee", 0) + frappe.db.set_single_value("Payroll Settings", "payroll_based_on", "Leave") + + +def make_department(department_name): + name = f"{department_name} - _TC" + if not frappe.db.exists("Department", name): + frappe.get_doc( + {"doctype": "Department", "department_name": department_name, "company": COMPANY} + ).insert() + return name + + +def make_payroll_employee(email, **kwargs): + return make_employee(email, company=COMPANY, **kwargs) + + +def assign_salary_structure(employee, structure_name, base=50000, from_date=None, **other_details): + return make_salary_structure( + structure_name, + "Monthly", + employee, + from_date=from_date or get_first_day(nowdate()), + company=COMPANY, + base=base, + other_details=other_details or None, + ) + + +def make_test_payroll_entry(department=None, submit=True): + dates = get_start_end_dates("Monthly", nowdate()) + payroll_entry = get_payroll_entry( + start_date=dates.start_date, + end_date=dates.end_date, + payable_account=PAYROLL_PAYABLE_ACCOUNT, + currency="INR", + company=COMPANY, + department=department, + cost_center="Main - _TC", + ) + if submit: + payroll_entry.submit() + payroll_entry.reload() + return payroll_entry + + +def make_salary_slip(employee, salary_structure): + slip = make_salary_slip_from_structure(salary_structure, employee=employee, posting_date=nowdate()) + slip.insert() + return slip + + +def get_slips(payroll_entry, docstatus=None): + filters = {"payroll_entry": payroll_entry} + if docstatus is not None: + filters["docstatus"] = docstatus + return frappe.get_all("Salary Slip", filters=filters, pluck="name") From c0031e8879bf06b695e0b253c63371b2e0815848 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:50 +0300 Subject: [PATCH 12/74] test: add shared fixtures for the import tracker tests --- csf_tz/tests/import_fixtures.py | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 csf_tz/tests/import_fixtures.py diff --git a/csf_tz/tests/import_fixtures.py b/csf_tz/tests/import_fixtures.py new file mode 100644 index 00000000..701e5a9f --- /dev/null +++ b/csf_tz/tests/import_fixtures.py @@ -0,0 +1,86 @@ +"""Builders shared by the payments and foreign import tests.""" + +import frappe +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + +COMPANY = "_Test Company" +USD_SUPPLIER = "_Test Supplier USD" +INR_SUPPLIER = "_Test Supplier" +USD_BANK = "_Test Bank USD - _TC" +INR_BANK = "_Test Bank - _TC" +ORIGINAL_RATE = 2500.0 + + +def set_import_settings(**values): + """Configure Foreign Import Settings and allow foreign invoices on company-currency party accounts.""" + frappe.db.set_single_value( + "Accounts Settings", "allow_multi_currency_invoices_against_single_party_account", 1 + ) + settings = { + "company": COMPANY, + "exchange_difference_threshold": 0.01, + "auto_create_journal_entries": 0, + "enable_lcv_exchange_tracking": 1, + "default_exchange_gain_account": None, + "default_exchange_loss_account": None, + } + settings.update(values) + for field, value in settings.items(): + frappe.db.set_single_value("Foreign Import Settings", field, value) + + +def make_foreign_purchase_invoice(**args): + args.setdefault("supplier", USD_SUPPLIER) + args.setdefault("currency", "USD") + args.setdefault("conversion_rate", ORIGINAL_RATE) + args.setdefault("rate", 100) + args.setdefault("qty", 10) + return make_purchase_invoice(**args) + + +def get_tracker(purchase_invoice): + name = frappe.db.get_value("Foreign Import Transaction", {"purchase_invoice": purchase_invoice}, "name") + return frappe.get_doc("Foreign Import Transaction", name) if name else None + + +def make_supplier_payment(purchase_invoice, amount, rate, bank_account=USD_BANK): + """Pay `amount` in the invoice currency at `rate`, from the given bank account.""" + invoice_currency = frappe.db.get_value("Purchase Invoice", purchase_invoice, "currency") + payment = get_payment_entry("Purchase Invoice", purchase_invoice, bank_account=bank_account) + if payment.paid_from_account_currency == invoice_currency: + payment.paid_amount, payment.source_exchange_rate = amount, rate + else: + payment.paid_amount, payment.source_exchange_rate = amount * rate, 1 + if payment.paid_to_account_currency == invoice_currency: + payment.received_amount, payment.target_exchange_rate = amount, rate + else: + payment.received_amount, payment.target_exchange_rate = amount * rate, 1 + reference = payment.references[0] + reference.allocated_amount = min(payment.received_amount, reference.outstanding_amount) + payment.reference_no = "TEST-REF" + payment.reference_date = frappe.utils.nowdate() + payment.insert() + payment.submit() + return payment + + +def make_plain_supplier_payment(supplier, amount, **values): + payment = frappe.new_doc("Payment Entry") + payment.update( + { + "payment_type": "Pay", + "party_type": "Supplier", + "party": supplier, + "company": COMPANY, + "paid_from": INR_BANK, + "paid_amount": amount, + "received_amount": amount, + "source_exchange_rate": 1, + "target_exchange_rate": 1, + "reference_no": "TEST-REF", + "reference_date": frappe.utils.nowdate(), + } + ) + payment.update(values) + return payment From f32058dccec37dc46e54b02d75605cda632b63e4 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:55 +0300 Subject: [PATCH 13/74] test: add shared fixtures for the integration tests --- csf_tz/tests/integration_fixtures.py | 130 +++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 csf_tz/tests/integration_fixtures.py diff --git a/csf_tz/tests/integration_fixtures.py b/csf_tz/tests/integration_fixtures.py new file mode 100644 index 00000000..69963a0e --- /dev/null +++ b/csf_tz/tests/integration_fixtures.py @@ -0,0 +1,130 @@ +"""Shared fakes and record stubs for the csf_tz integration tests.""" + +import datetime +import json + +import frappe +import pgpy +import requests +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import NameOID +from frappe.utils import nowdate +from pgpy.constants import ( + CompressionAlgorithm, + HashAlgorithm, + KeyFlags, + PubKeyAlgorithm, + SymmetricKeyAlgorithm, +) + +COMPANY = "_Test Company" + + +class FakeResponse: + """Minimal stand-in for requests.Response.""" + + def __init__(self, status_code=200, body=None, text=None): + self.status_code = status_code + self.body = body + self.text = text if text is not None else json.dumps(body or {}) + + def json(self): + if self.body is None: + raise ValueError("no json body") + return self.body + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.exceptions.HTTPError(f"HTTP {self.status_code}") + + +def make_pgp_key(): + """Return a fresh private PGP key; str(key.pubkey) is the armored public key.""" + key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) + key.add_uid( + pgpy.PGPUID.new("csf_tz test"), + usage={KeyFlags.Sign, KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, + hashes=[HashAlgorithm.SHA256], + ciphers=[SymmetricKeyAlgorithm.AES256], + compression=[CompressionAlgorithm.ZLIB], + ) + return key + + +def pgp_decrypt(key, armored_message): + return key.decrypt(pgpy.PGPMessage.from_blob(armored_message)).message + + +def make_p12(password): + """Return (p12_bytes, private_key) for a self-signed certificate.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "csf_tz test")]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=1)) + .sign(private_key, hashes.SHA256()) + ) + p12_bytes = pkcs12.serialize_key_and_certificates( + b"csf_tz", + private_key, + certificate, + None, + serialization.BestAvailableEncryption(password.encode()), + ) + return p12_bytes, private_key + + +def insert_stub(values, docstatus=0): + """Insert a record without running its controller validations.""" + doc = frappe.get_doc(values) + doc.flags.ignore_validate = True + doc.flags.ignore_permissions = True + doc.insert(ignore_mandatory=True, ignore_links=True) + if docstatus: + frappe.db.set_value(doc.doctype, doc.name, "docstatus", docstatus, update_modified=False) + doc.reload() + return doc + + +def make_payroll_entry_stub(company=COMPANY, currency="INR"): + return insert_stub( + { + "doctype": "Payroll Entry", + "company": company, + "posting_date": nowdate(), + "start_date": nowdate(), + "end_date": nowdate(), + "currency": currency, + "exchange_rate": 1, + "payroll_frequency": "Monthly", + } + ) + + +def make_salary_slip_stub(payroll_entry, employee, net_pay, currency="INR", docstatus=1): + return insert_stub( + { + "doctype": "Salary Slip", + "employee": employee, + "employee_name": frappe.db.get_value("Employee", employee, "employee_name"), + "company": payroll_entry.company, + "payroll_entry": payroll_entry.name, + "posting_date": nowdate(), + "start_date": nowdate(), + "end_date": nowdate(), + "currency": currency, + "exchange_rate": 1, + "net_pay": net_pay, + "gross_pay": net_pay, + }, + docstatus=docstatus, + ) From 8df681b7cd3ca8358d33fdc1fc4c612edf2598b7 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:55:59 +0300 Subject: [PATCH 14/74] test: add shared fixtures for the report tests --- csf_tz/tests/report_fixtures.py | 116 ++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 csf_tz/tests/report_fixtures.py diff --git a/csf_tz/tests/report_fixtures.py b/csf_tz/tests/report_fixtures.py new file mode 100644 index 00000000..4396cba2 --- /dev/null +++ b/csf_tz/tests/report_fixtures.py @@ -0,0 +1,116 @@ +"""Shared helpers for the csf_tz report tests.""" + +import frappe +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from frappe.desk.query_report import run +from frappe.utils import add_days, get_first_day, get_last_day, today + +COMPANY = "_Test Company" +ITEM = "_Test Item" +WAREHOUSE = "_Test Warehouse - _TC" +COST_CENTER = "_Test Cost Center - _TC" +BANK_ACCOUNT_GL = "_Test Bank - _TC" + + +def run_report(report_name, filters=None): + """Run a report the way the desk does and return columns and result rows.""" + result = run(report_name, filters=filters or {}, ignore_prepared_report=True) + return result["columns"], result["result"] + + +def fieldnames(columns): + names = [] + for column in columns: + if isinstance(column, dict): + names.append(column.get("fieldname") or column.get("label")) + else: + names.append(column.split(":")[0]) + return names + + +def as_dicts(columns, rows): + names = fieldnames(columns) + return [row if isinstance(row, dict) else dict(zip(names, row, strict=False)) for row in rows] + + +def date_range(): + return {"from_date": add_days(today(), -30), "to_date": add_days(today(), 30)} + + +def month_range(): + return {"from_date": get_first_day(today()), "to_date": get_last_day(today())} + + +def receive_stock(item_code=ITEM, qty=50, rate=100, warehouse=WAREHOUSE): + return make_stock_entry( + item_code=item_code, + target=warehouse, + qty=qty, + rate=rate, + company=COMPANY, + posting_date=today(), + ) + + +def sell_stock(item_code=ITEM, qty=2, rate=500, **args): + """Submit a stock-updating Sales Invoice that skips the VFD fiscalisation checks.""" + invoice = create_sales_invoice( + item_code=item_code, qty=qty, rate=rate, update_stock=1, posting_date=today(), do_not_save=1, **args + ) + invoice.is_not_vfd_invoice = 1 + invoice.insert() + invoice.submit() + invoice.load_from_db() + return invoice + + +def make_bank_account(account_name="_Test Report Bank Account", bank="_Test Report Bank"): + if not frappe.db.exists("Bank", bank): + frappe.get_doc({"doctype": "Bank", "bank_name": bank}).insert() + name = f"{account_name} - {bank}" + if not frappe.db.exists("Bank Account", name): + frappe.get_doc( + { + "doctype": "Bank Account", + "account_name": account_name, + "bank": bank, + "account": BANK_ACCOUNT_GL, + "is_company_account": 1, + "company": COMPANY, + } + ).insert() + return name + + +def make_bank_transaction(bank_account, deposit=0, withdrawal=0, description="Test transaction"): + transaction = frappe.get_doc( + { + "doctype": "Bank Transaction", + "date": today(), + "bank_account": bank_account, + "company": COMPANY, + "deposit": deposit, + "withdrawal": withdrawal, + "description": description, + "reference_number": frappe.generate_hash(length=8), + } + ).insert() + transaction.submit() + return transaction + + +def make_currency_exchange(from_currency, to_currency, rate): + for currency in (from_currency, to_currency): + frappe.db.set_value("Currency", currency, "enabled", 1) + return frappe.get_doc( + { + "doctype": "Currency Exchange", + "date": today(), + "from_currency": from_currency, + "to_currency": to_currency, + "exchange_rate": rate, + "for_buying": 1, + "for_selling": 1, + } + ).insert() From 63878213b23ade0ca4a281d3370035494d3e95ba Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:03 +0300 Subject: [PATCH 15/74] test: cover budget validation on orders, invoices and journals --- csf_tz/tests/test_budget_checks.py | 154 +++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 csf_tz/tests/test_budget_checks.py diff --git a/csf_tz/tests/test_budget_checks.py b/csf_tz/tests/test_budget_checks.py new file mode 100644 index 00000000..eaca65d7 --- /dev/null +++ b/csf_tz/tests/test_budget_checks.py @@ -0,0 +1,154 @@ +from unittest.mock import patch + +import frappe +from erpnext.accounts.doctype.budget.budget import BudgetError +from erpnext.accounts.doctype.budget.test_budget import make_budget +from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from frappe.tests import IntegrationTestCase +from frappe.utils import nowdate + +from csf_tz.budget_check import ( + check_budget_before_submit, + check_budget_for_buying_document, + check_budget_for_journal_entry, + is_budget_check_enabled, + validate_budget_on_draft, +) +from csf_tz.csftz_hooks.budget import check_budget_for_purchase_order +from csf_tz.tests.import_fixtures import COMPANY + +EXPENSE_ACCOUNT = "_Test Account Cost for Goods Sold - _TC" +COST_CENTER = "_Test Cost Center - _TC" +CASH_ACCOUNT = "Cash - _TC" +BUDGET_AMOUNT = 100000 +OVER_BUDGET = BUDGET_AMOUNT * 2 + + +class TestBudgetChecks(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_budget( + budget_against="Cost Center", + cost_center=COST_CENTER, + budget_amount=BUDGET_AMOUNT, + applicable_on_material_request=1, + action_if_annual_budget_exceeded_on_mr="Stop", + applicable_on_purchase_order=1, + action_if_annual_budget_exceeded_on_po="Stop", + submit_budget=1, + ) + + def enable(self, field): + frappe.db.set_single_value("CSF TZ Settings", field, 1) + self.addCleanup(frappe.db.set_single_value, "CSF TZ Settings", field, 0) + + def make_material_request(self, rate, qty=1): + request = frappe.new_doc("Material Request") + request.update({"material_request_type": "Purchase", "company": COMPANY, "schedule_date": nowdate()}) + request.append( + "items", + { + "item_code": "_Test Item", + "qty": qty, + "rate": rate, + "amount": qty * rate, + "uom": "_Test UOM", + "conversion_factor": 1, + "schedule_date": nowdate(), + "warehouse": "_Test Warehouse - _TC", + "cost_center": COST_CENTER, + "expense_account": EXPENSE_ACCOUNT, + }, + ) + request.insert() + return request + + def test_purchase_invoice_over_budget_is_blocked(self): + self.enable("check_budget_in_pi") + with self.assertRaises(BudgetError): + make_purchase_invoice(rate=OVER_BUDGET, qty=1, do_not_submit=True) + + def test_purchase_invoice_within_budget_saves(self): + self.enable("check_budget_in_pi") + invoice = make_purchase_invoice(rate=10, qty=1, do_not_submit=True) + self.assertTrue(invoice.name) + + def test_purchase_invoice_check_is_off_by_default(self): + invoice = make_purchase_invoice(rate=OVER_BUDGET, qty=1, do_not_submit=True) + self.assertTrue(invoice.name) + + def test_material_request_over_budget_is_blocked(self): + self.enable("check_budget_in_mr") + with self.assertRaises(BudgetError): + self.make_material_request(OVER_BUDGET) + self.assertTrue(self.make_material_request(10).name) + + def test_journal_entry_over_budget_is_blocked(self): + self.enable("check_budget_in_je") + with self.assertRaises(BudgetError): + make_journal_entry(EXPENSE_ACCOUNT, CASH_ACCOUNT, OVER_BUDGET, cost_center=COST_CENTER) + credit_note = make_journal_entry(CASH_ACCOUNT, EXPENSE_ACCOUNT, OVER_BUDGET, cost_center=COST_CENTER) + self.assertTrue(credit_note.name) + + def test_purchase_order_over_budget_is_blocked(self): + order = create_purchase_order(rate=OVER_BUDGET, qty=1, do_not_submit=True) + self.enable("check_budget_in_po") + with self.assertRaises(BudgetError): + check_budget_for_purchase_order(order) + with self.assertRaises(BudgetError): + order.save() + frappe.db.set_single_value("CSF TZ Settings", "check_budget_in_po", 0) + self.assertIsNone(check_budget_for_purchase_order(order)) + + def test_is_budget_check_enabled(self): + self.assertFalse(is_budget_check_enabled("Sales Invoice")) + self.assertFalse(is_budget_check_enabled("Journal Entry")) + + def test_validate_budget_on_draft(self): + journal_entry = make_journal_entry( + EXPENSE_ACCOUNT, CASH_ACCOUNT, OVER_BUDGET, cost_center=COST_CENTER + ) + self.assertIsNone(validate_budget_on_draft(journal_entry)) + with patch("csf_tz.budget_check.is_budget_check_enabled", return_value=True): + with self.assertRaises(BudgetError): + validate_budget_on_draft(journal_entry) + journal_entry.docstatus = 1 + self.assertIsNone(validate_budget_on_draft(journal_entry)) + + def test_check_budget_before_submit(self): + journal_entry = make_journal_entry( + EXPENSE_ACCOUNT, CASH_ACCOUNT, OVER_BUDGET, cost_center=COST_CENTER + ) + self.assertIsNone(check_budget_before_submit("Journal Entry", journal_entry.name)) + self.enable("check_budget_in_je") + with self.assertRaises(BudgetError): + check_budget_before_submit( + "Journal Entry", journal_entry.name, setting_field="check_budget_in_je" + ) + self.assertIsNone(check_budget_before_submit(None, None)) + self.assertIsNone(check_budget_before_submit("Sales Invoice", journal_entry.name)) + self.assertIsNone( + check_budget_before_submit("Journal Entry", "MISSING", setting_field="check_budget_in_je") + ) + + def test_check_budget_for_documents_directly(self): + with self.assertRaises(BudgetError): + check_budget_for_journal_entry( + make_journal_entry(EXPENSE_ACCOUNT, CASH_ACCOUNT, OVER_BUDGET, cost_center=COST_CENTER) + ) + with self.assertRaises(BudgetError): + check_budget_for_buying_document(self.make_material_request(OVER_BUDGET)) + with self.assertRaises(BudgetError): + check_budget_for_buying_document( + make_purchase_invoice(rate=OVER_BUDGET, qty=1, do_not_submit=True) + ) + with self.assertRaises(BudgetError): + check_budget_for_buying_document( + create_purchase_order(rate=OVER_BUDGET, qty=1, do_not_submit=True) + ) + self.assertIsNone( + check_budget_for_buying_document(make_purchase_invoice(rate=10, qty=1, do_not_submit=True)) + ) From 7e418295cb702443805bc48dc050dabd7fcc28ee Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:08 +0300 Subject: [PATCH 16/74] test: assert client scripts call methods that exist --- csf_tz/tests/test_client_script_refs.py | 69 +++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 csf_tz/tests/test_client_script_refs.py diff --git a/csf_tz/tests/test_client_script_refs.py b/csf_tz/tests/test_client_script_refs.py new file mode 100644 index 00000000..c8029eb0 --- /dev/null +++ b/csf_tz/tests/test_client_script_refs.py @@ -0,0 +1,69 @@ +"""Every server method referenced by a csf_tz Desk client script must exist and be whitelisted.""" + +import glob +import os +import re + +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz import hooks + +APP_PATH = frappe.get_app_path("csf_tz") +METHOD_PATTERN = re.compile( + r"""(?:\bmethod\b|\bquery\b)\s*['"]?\s*:\s*['"]([\w.]+\.[\w]+)['"]""" + r"""|frappe\.(?:call|xcall)\(\s*['"]([\w.]+\.[\w]+)['"]""" +) +KNOWN_BROKEN = {} + + +def client_script_files(): + files = set(glob.glob(os.path.join(APP_PATH, "public", "js", "*.js"))) + for scripts in (*hooks.doctype_js.values(), *hooks.doctype_list_js.values()): + for script in scripts if isinstance(scripts, list) else [scripts]: + files.add(os.path.join(APP_PATH, script)) + return sorted(files) + + +def referenced_methods(path): + with open(path) as script: + content = script.read() + return sorted({match.group(1) or match.group(2) for match in METHOD_PATTERN.finditer(content)}) + + +def resolve_error(method): + try: + frappe.is_whitelisted(frappe.get_attr(method)) + except Exception as error: + return f"{type(error).__name__}: {str(error)[:120]}" + return None + + +class TestClientScriptReferences(IntegrationTestCase): + def test_hooked_script_files_exist(self): + missing = [path for path in client_script_files() if not os.path.exists(path)] + self.assertEqual(missing, []) + + def test_scan_finds_references(self): + methods = {method for path in client_script_files() for method in referenced_methods(path)} + self.assertIn("csf_tz.custom_api.get_item_info", methods) + self.assertIn("csf_tz.csftz_hooks.payroll.update_slips", methods) + + def test_every_referenced_method_resolves_and_is_whitelisted(self): + broken = [] + for path in client_script_files(): + relative = os.path.relpath(path, APP_PATH) + if relative in KNOWN_BROKEN: + continue + for method in referenced_methods(path): + error = resolve_error(method) + if error: + broken.append(f"{relative}: {method} -> {error}") + self.assertEqual(broken, [], "\n".join(broken)) + + def test_known_broken_scripts_are_still_broken(self): + for relative in KNOWN_BROKEN: + errors = [ + resolve_error(method) for method in referenced_methods(os.path.join(APP_PATH, relative)) + ] + self.assertTrue(any(errors), f"{relative} resolves now; remove it from KNOWN_BROKEN") From 15f02c6a9d5a013b81bb756d4d8b04dff976a69b Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:13 +0300 Subject: [PATCH 17/74] test: cover automatic delivery note creation and closing --- csf_tz/tests/test_custom_api_delivery_note.py | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 csf_tz/tests/test_custom_api_delivery_note.py diff --git a/csf_tz/tests/test_custom_api_delivery_note.py b/csf_tz/tests/test_custom_api_delivery_note.py new file mode 100644 index 00000000..23140c9f --- /dev/null +++ b/csf_tz/tests/test_custom_api_delivery_note.py @@ -0,0 +1,166 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, nowdate + +from csf_tz.custom_api import ( + auto_close_dn, + cancle_linked_docs, + create_delivery_note_for_all_pending_sales_invoice, + delete_doc, + get_linked_docs_info, + get_list_pending_sales_invoice, + get_pending_sales_invoice, + make_delivery_note, +) +from csf_tz.tests.custom_api_helpers import ( + COMPANY, + CUSTOMER, + WAREHOUSE, + add_stock, + disable_db_commit, + make_sales_invoice, + make_test_item, +) +from csf_tz.tests.custom_api_helpers import ( + make_delivery_note as make_plain_delivery_note, +) + + +def draft_delivery_notes(invoice_name): + return frappe.get_all( + "Delivery Note", filters={"form_sales_invoice": invoice_name, "docstatus": 0}, pluck="name" + ) + + +class TestAutoDeliveryNote(IntegrationTestCase): + """Sales Invoice submit creates a draft Delivery Note and tracks delivery status.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_test_item("_CSF Auto DN Item") + cls.service = make_test_item("_CSF Auto DN Service", is_stock_item=0) + add_stock(cls.item.name, qty=50, rate=20) + + def setUp(self): + disable_db_commit(self) + + def test_draft_delivery_note_is_created_on_submit(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2) + notes = draft_delivery_notes(invoice.name) + self.assertEqual(len(notes), 1) + note = frappe.get_doc("Delivery Note", notes[0]) + self.assertEqual(note.items[0].si_detail, invoice.items[0].name) + self.assertEqual(note.items[0].qty, 2) + self.assertEqual(invoice.delivery_status, "Not Delivered") + self.assertEqual(invoice.items[0].delivery_status, "Not Delivered") + + def test_pending_invoice_queries(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2) + delete_doc("Delivery Note", draft_delivery_notes(invoice.name)[0]) + pending = get_list_pending_sales_invoice(invoice.name, WAREHOUSE) + self.assertEqual(pending[0].name, invoice.name) + filters = {"customer": CUSTOMER, "company": COMPANY, "set_warehouse": WAREHOUSE} + names = [row.name for row in get_pending_sales_invoice("Sales Invoice", "", "name", 0, 50, filters)] + self.assertIn(invoice.name, names) + date_filters = {"posting_date": ["Between", [nowdate(), nowdate()]]} + names = [ + row.name + for row in get_pending_sales_invoice("Sales Invoice", invoice.name, "name", 0, 50, date_filters) + ] + self.assertEqual(names, [invoice.name]) + self.assertEqual(get_pending_sales_invoice("Sales Invoice", "NOPE", "name", 0, 50, {}), []) + + def test_submitting_delivery_note_marks_invoice_delivered(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2) + note = frappe.get_doc("Delivery Note", draft_delivery_notes(invoice.name)[0]) + note.submit() + invoice.reload() + self.assertEqual(invoice.delivery_status, "Delivered") + self.assertEqual(invoice.items[0].delivery_status, "Delivered") + self.assertEqual(invoice.items[0].delivered_qty, 2) + linked = get_linked_docs_info("Sales Invoice", invoice.name) + self.assertIn( + ("Delivery Note", note.name, 1), [(d["doctype"], d["docname"], d["docstatus"]) for d in linked] + ) + cancle_linked_docs([{"doctype": "Delivery Note", "docname": note.name, "docstatus": 1}]) + self.assertEqual(frappe.db.get_value("Delivery Note", note.name, "docstatus"), 2) + + def test_update_stock_invoice_is_delivered_on_submit(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2, update_stock=1) + self.assertEqual(draft_delivery_notes(invoice.name), []) + self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "delivery_status"), "Delivered") + row = frappe.db.get_value( + "Sales Invoice Item", invoice.items[0].name, ["delivered_qty", "delivery_status"], as_dict=True + ) + self.assertEqual((row.delivered_qty, row.delivery_status), (2, "Delivered")) + + def test_non_stock_item_is_part_delivered_on_submit(self): + invoice = make_sales_invoice(item_code=self.service.name, warehouse=None) + self.assertEqual(draft_delivery_notes(invoice.name), []) + self.assertEqual( + frappe.db.get_value("Sales Invoice", invoice.name, "delivery_status"), "Part Delivered" + ) + + def test_cancel_resets_delivery_status(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2, update_stock=1) + invoice.reload() + invoice.cancel() + self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "delivery_status"), "Delivered") + invoice = make_sales_invoice(item_code=self.item.name, qty=2) + delete_doc("Delivery Note", draft_delivery_notes(invoice.name)[0]) + invoice.reload() + invoice.cancel() + self.assertEqual( + frappe.db.get_value("Sales Invoice", invoice.name, "delivery_status"), "Not Delivered" + ) + self.assertEqual(frappe.db.get_value("Sales Invoice Item", invoice.items[0].name, "delivered_qty"), 0) + + def test_make_delivery_note_whitelisted(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2) + delete_doc("Delivery Note", draft_delivery_notes(invoice.name)[0]) + note = make_delivery_note(invoice.name, None, WAREHOUSE) + self.assertEqual(note.doctype, "Delivery Note") + self.assertEqual([(row.item_code, row.qty) for row in note.items], [(self.item.name, 2)]) + note = make_delivery_note(invoice.name, None, "_Test Warehouse 1 - _TC") + self.assertEqual(note.items, []) + draft = make_sales_invoice(item_code=self.item.name, qty=2, do_not_submit=True) + self.assertRaises(frappe.ValidationError, make_delivery_note, draft.name) + + def test_scheduler_creates_notes_for_pending_invoices(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2) + delete_doc("Delivery Note", draft_delivery_notes(invoice.name)[0]) + create_delivery_note_for_all_pending_sales_invoice() + self.assertEqual(len(draft_delivery_notes(invoice.name)), 1) + + +class TestAutoCloseDeliveryNote(IntegrationTestCase): + """Daily job closes old Delivery Notes for customers that opted in.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.service = make_test_item("_CSF Auto Close Service", is_stock_item=0) + + def setUp(self): + disable_db_commit(self) + frappe.db.set_value("Customer", CUSTOMER, {"csf_tz_is_auto_close_dn": 1, "csf_tz_close_dn_after": 5}) + + def test_old_notes_are_closed_and_recent_ones_kept(self): + old = make_plain_delivery_note( + item_code=self.service.name, warehouse=None, posting_date=add_days(nowdate(), -10) + ) + recent = make_plain_delivery_note( + item_code=self.service.name, warehouse=None, posting_date=add_days(nowdate(), -2) + ) + auto_close_dn() + self.assertEqual(frappe.db.get_value("Delivery Note", old.name, "status"), "Closed") + self.assertNotEqual(frappe.db.get_value("Delivery Note", recent.name, "status"), "Closed") + + def test_no_customer_opted_in_is_noop(self): + frappe.db.set_value("Customer", CUSTOMER, "csf_tz_is_auto_close_dn", 0) + old = make_plain_delivery_note( + item_code=self.service.name, warehouse=None, posting_date=add_days(nowdate(), -10) + ) + auto_close_dn() + self.assertNotEqual(frappe.db.get_value("Delivery Note", old.name, "status"), "Closed") From 5cc961e6955487c5957e55276e76d1d5e9535354 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:17 +0300 Subject: [PATCH 18/74] test: cover the item price and warehouse endpoints --- csf_tz/tests/test_custom_api_item_prices.py | 100 ++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 csf_tz/tests/test_custom_api_item_prices.py diff --git a/csf_tz/tests/test_custom_api_item_prices.py b/csf_tz/tests/test_custom_api_item_prices.py new file mode 100644 index 00000000..b5707cdd --- /dev/null +++ b/csf_tz/tests/test_custom_api_item_prices.py @@ -0,0 +1,100 @@ +import json + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import nowdate + +from csf_tz.custom_api import ( + get_item_info, + get_item_prices, + get_item_prices_custom, + get_item_prices_custom_po, + get_item_prices_po, + get_pending_sales_invoice, + get_warehouse_options, +) +from csf_tz.tests.custom_api_helpers import ( + COMPANY, + CUSTOMER, + SUPPLIER, + WAREHOUSE, + disable_db_commit_for_class, + make_purchase_invoice, + make_sales_invoice, + make_test_item, + set_csf_settings, +) + + +class TestItemPrices(IntegrationTestCase): + """Price history dialogs read submitted Sales and Purchase Invoices.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + disable_db_commit_for_class(cls) + cls.item = make_test_item("_CSF Price History Item") + for rate in (100, 100, 120): + make_sales_invoice(item_code=cls.item.name, rate=rate) + for rate in (50, 50, 60): + make_purchase_invoice(item_code=cls.item.name, rate=rate) + + def setUp(self): + set_csf_settings(unique_records=0) + + def test_sales_prices_all_records(self): + rows = get_item_prices(self.item.name, "INR", CUSTOMER, COMPANY) + self.assertEqual(sorted(row["price"] for row in rows), [100, 100, 120]) + self.assertEqual({row["customer"] for row in rows}, {CUSTOMER}) + + def test_sales_prices_unique(self): + set_csf_settings(unique_records=1) + rows = get_item_prices(self.item.name, "INR", None, COMPANY) + self.assertEqual(sorted(row["price"] for row in rows), [100, 120]) + + def test_sales_prices_custom_filters(self): + filters = { + "item_code": self.item.name, + "currency": "INR", + "customer": CUSTOMER, + "company": COMPANY, + "posting_date": ["Between", [nowdate(), nowdate()]], + } + rows = get_item_prices_custom(json.dumps(filters), 0, 20) + self.assertEqual(sorted(row["rate"] for row in rows), [100, 100, 120]) + self.assertEqual({row["posting_date"] for row in rows}, {frappe.utils.getdate(nowdate())}) + self.assertEqual(get_item_prices_custom(None), []) + + def test_sales_prices_custom_invalid_json(self): + self.assertRaises(frappe.ValidationError, get_item_prices_custom, "{not json") + + def test_purchase_prices(self): + rows = get_item_prices_po(self.item.name, "INR", SUPPLIER, COMPANY) + self.assertEqual(sorted(row["price"] for row in rows), [50, 50, 60]) + set_csf_settings(unique_records=1) + filters = {"item_code": self.item.name, "currency": "INR", "customer": SUPPLIER, "company": COMPANY} + rows = get_item_prices_custom_po(filters) + self.assertEqual(sorted(row["rate"] for row in rows), [50, 60]) + + def test_warehouse_options(self): + warehouses = get_warehouse_options(COMPANY) + self.assertIn(WAREHOUSE, warehouses) + self.assertNotIn("All Warehouses - _TC", warehouses) + + def test_item_info_for_plain_item(self): + rows = get_item_info("_Test Item") + self.assertIn((WAREHOUSE, ""), [(row["warehouse"], row["batch_no"]) for row in rows]) + self.assertEqual(get_item_info(self.item.name), []) + + def test_guest_cannot_read_price_history(self): + with self.set_user("Guest"): + self.assertRaises(frappe.PermissionError, get_item_prices, self.item.name, "INR", None, COMPANY) + self.assertRaises(frappe.PermissionError, get_item_prices_custom, {"item_code": self.item.name}) + self.assertRaises( + frappe.PermissionError, get_item_prices_po, self.item.name, "INR", None, COMPANY + ) + self.assertRaises(frappe.PermissionError, get_item_prices_custom_po, {}) + self.assertRaises(frappe.PermissionError, get_item_info, self.item.name) + self.assertRaises( + frappe.PermissionError, get_pending_sales_invoice, "Sales Invoice", "", "name", 0, 20, {} + ) From d5c990849cfa1d24dec6b8da3167065b5d117f63 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:22 +0300 Subject: [PATCH 19/74] test: cover the qrcode, logging and stock helpers --- csf_tz/tests/test_custom_api_misc.py | 116 +++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 csf_tz/tests/test_custom_api_misc.py diff --git a/csf_tz/tests/test_custom_api_misc.py b/csf_tz/tests/test_custom_api_misc.py new file mode 100644 index 00000000..3bb666c9 --- /dev/null +++ b/csf_tz/tests/test_custom_api_misc.py @@ -0,0 +1,116 @@ +import frappe +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, nowdate + +from csf_tz.custom_api import ( + app_error_log, + generate_qrcode, + get_pending_material_request, + get_stock_balance_for, + make_stock_reconciliation_for_all_pending_material_request, + print_out, +) +from csf_tz.tests.custom_api_helpers import ( + COMPANY, + WAREHOUSE, + add_stock, + disable_db_commit, + make_test_item, + set_csf_settings, +) + + +class TestSmallHelpers(IntegrationTestCase): + """QR code, error log and console helpers.""" + + def setUp(self): + disable_db_commit(self) + + def test_generate_qrcode(self): + self.assertTrue(generate_qrcode("hello").startswith("data:image/png;base64,")) + + def test_app_error_log(self): + self.assertIsNone(app_error_log("title", "error")) + + def test_print_out_variants(self): + self.assertIsNone(print_out(None)) + frappe.local.message_log = [] + print_out("plain text", alert=True) + self.assertIn("plain text", frappe.local.message_log[0]["message"]) + print_out(["a", 1, 2.5, {"k": "v"}, frappe._dict(k="v"), self], add_traceback=True) + print_out("_CSF print_out to error log", to_error_log=True) + self.assertTrue(frappe.db.exists("Error Log", {"method": "_CSF print_out to error log"})) + + +class TestTotalNetWeight(IntegrationTestCase): + """Stock Entry validate hook sums row weights.""" + + def test_total_net_weight(self): + entry = make_stock_entry( + item_code="_Test Item", qty=1, rate=100, to_warehouse=WAREHOUSE, company=COMPANY, do_not_save=True + ) + entry.items[0].total_weight = 5.5 + entry.insert() + self.assertEqual(entry.total_net_weight, 5.5) + entry.items[0].total_weight = 0 + entry.save() + self.assertEqual(entry.total_net_weight, 0) + + +class TestStockReconciliationForMaterialRequests(IntegrationTestCase): + """Weekly job drafts a Stock Reconciliation for pending Material Requests.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_test_item("_CSF MR Reco Item") + add_stock(cls.item.name, qty=5, rate=20) + cls.request = frappe.get_doc( + doctype="Material Request", + material_request_type="Purchase", + company=COMPANY, + transaction_date=nowdate(), + schedule_date=add_days(nowdate(), 7), + items=[ + { + "item_code": cls.item.name, + "qty": 3, + "warehouse": WAREHOUSE, + "schedule_date": add_days(nowdate(), 7), + } + ], + ).insert() + cls.request.submit() + + def setUp(self): + disable_db_commit(self) + set_csf_settings(auto_stock_reconciliation=1) + + def test_stock_balance_and_pending_requests(self): + self.assertEqual( + get_stock_balance_for(self.item.name, WAREHOUSE), {"qty": 5, "rate": 20, "serial_nos": ""} + ) + self.assertIn(self.request.name, [row.name for row in get_pending_material_request()]) + + def test_reconciliation_is_created(self): + make_stock_reconciliation_for_all_pending_material_request() + reconciliation_name = frappe.db.get_value( + "Material Request Item", self.request.items[0].name, "stock_reconciliation" + ) + self.assertTrue(reconciliation_name) + reconciliation = frappe.get_doc("Stock Reconciliation", reconciliation_name) + self.assertEqual(reconciliation.docstatus, 0) + row = reconciliation.items[0] + self.assertEqual( + (row.item_code, row.warehouse, row.qty, row.valuation_rate), (self.item.name, WAREHOUSE, 8, 20) + ) + drafts = frappe.db.count("Stock Reconciliation", {"docstatus": 0}) + make_stock_reconciliation_for_all_pending_material_request() + self.assertEqual(frappe.db.count("Stock Reconciliation", {"docstatus": 0}), drafts) + + def test_disabled_setting_is_noop(self): + set_csf_settings(auto_stock_reconciliation=0) + drafts = frappe.db.count("Stock Reconciliation", {"docstatus": 0}) + make_stock_reconciliation_for_all_pending_material_request() + self.assertEqual(frappe.db.count("Stock Reconciliation", {"docstatus": 0}), drafts) From 22ad711e8591d3039e7247254734db50e58bfc54 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:27 +0300 Subject: [PATCH 20/74] test: cover the sales invoice hooks in custom_api --- csf_tz/tests/test_custom_api_sales_invoice.py | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 csf_tz/tests/test_custom_api_sales_invoice.py diff --git a/csf_tz/tests/test_custom_api_sales_invoice.py b/csf_tz/tests/test_custom_api_sales_invoice.py new file mode 100644 index 00000000..e6aeb607 --- /dev/null +++ b/csf_tz/tests/test_custom_api_sales_invoice.py @@ -0,0 +1,165 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, nowdate + +from csf_tz.custom_api import get_item_info +from csf_tz.tests.custom_api_helpers import ( + WAREHOUSE, + add_stock, + disable_db_commit, + make_sales_invoice, + make_test_item, + set_csf_settings, +) + + +class TestValidateGrandTotal(IntegrationTestCase): + """before_submit hook: POS payments must match the invoice total.""" + + def setUp(self): + disable_db_commit(self) + set_csf_settings(validate_grand_total_vs_payment_amount_on_sales_invoice=1) + + def pos_invoice(self, paid_amount): + invoice = make_sales_invoice(is_pos=1, rate=100, do_not_save=True) + invoice.append( + "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": paid_amount} + ) + return invoice.insert() + + def test_throws_when_payment_differs_from_total(self): + invoice = self.pos_invoice(50) + self.assertRaises(frappe.ValidationError, invoice.submit) + + def test_submits_when_payment_matches_total(self): + invoice = self.pos_invoice(100) + invoice.submit() + self.assertEqual(invoice.docstatus, 1) + + def test_skipped_when_setting_is_off(self): + set_csf_settings(validate_grand_total_vs_payment_amount_on_sales_invoice=0) + invoice = self.pos_invoice(50) + invoice.submit() + self.assertEqual(invoice.docstatus, 1) + + +class TestValidateNetRate(IntegrationTestCase): + """on_submit hook: selling below last purchase or valuation rate is blocked.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.purchase_item = make_test_item("_CSF Net Rate Purchase Item", last_purchase_rate=200) + cls.valuation_item = make_test_item("_CSF Net Rate Valuation Item") + add_stock(cls.valuation_item.name, qty=10, rate=150) + + def setUp(self): + disable_db_commit(self) + set_csf_settings(validate_net_rate=1) + + def test_throws_below_last_purchase_rate(self): + invoice = make_sales_invoice(item_code=self.purchase_item.name, rate=100, do_not_submit=True) + self.assertRaisesRegex(frappe.ValidationError, "last purchase rate", invoice.submit) + + def test_throws_below_valuation_rate(self): + invoice = make_sales_invoice(item_code=self.valuation_item.name, rate=100, do_not_submit=True) + self.assertRaisesRegex(frappe.ValidationError, "valuation rate", invoice.submit) + + def test_passes_above_reference_rates(self): + invoice = make_sales_invoice(item_code=self.valuation_item.name, rate=300) + self.assertEqual(invoice.docstatus, 1) + + def test_override_flag_skips_row(self): + invoice = make_sales_invoice(item_code=self.purchase_item.name, rate=100, allow_override_net_rate=1) + self.assertEqual(invoice.docstatus, 1) + + def test_skipped_when_setting_is_off(self): + set_csf_settings(validate_net_rate=0) + invoice = make_sales_invoice(item_code=self.purchase_item.name, rate=100) + self.assertEqual(invoice.docstatus, 1) + + +class TestCalculatePriceReduction(IntegrationTestCase): + """validate hook: price_reduction sums qty * discount_amount.""" + + def setUp(self): + disable_db_commit(self) + + def test_price_reduction_is_set(self): + invoice = make_sales_invoice(qty=3, price_list_rate=100, rate=90, do_not_submit=True) + row = invoice.items[0] + self.assertEqual(row.discount_amount, 10) + self.assertEqual(invoice.price_reduction, 30) + + +class TestBatchSplitting(IntegrationTestCase): + """before_insert hook: rows are split per available batch when update_stock is set.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_test_item("_CSF Batch Split Item", has_batch_no=1, create_new_batch=0) + cls.batches = [] + for suffix, qty in (("A", 6), ("B", 10)): + batch = frappe.get_doc( + doctype="Batch", + item=cls.item.name, + batch_id=f"_CSF-SPLIT-{suffix}", + expiry_date=add_days(nowdate(), 30), + ).insert() + add_stock(cls.item.name, qty=qty, rate=10, batch_no=batch.name) + cls.batches.append(batch.name) + + def setUp(self): + disable_db_commit(self) + set_csf_settings(allow_batch_splitting=1) + frappe.db.set_single_value("Selling Settings", "allow_multiple_items", 1) + + def batch_invoice(self, rows, **args): + return make_sales_invoice( + update_stock=1, + set_warehouse=WAREHOUSE, + item_code=self.item.name, + rate=50, + rows=[dict(qty=qty, stock_qty=qty) for qty in rows], + **args, + ) + + def test_single_row_is_split_across_batches(self): + invoice = self.batch_invoice([10], do_not_submit=True) + allocations = {row.batch_no: row.qty for row in invoice.items} + self.assertEqual(allocations, {self.batches[0]: 6, self.batches[1]: 4}) + + def test_submit_consumes_batches_and_item_info_reflects_it(self): + self.batch_invoice([8]) + info = {row["batch_no"]: row["actual_qty"] for row in get_item_info(self.item.name)} + self.assertEqual(info.get(self.batches[0]), 0) + self.assertEqual(info.get(self.batches[1]), 8) + + def test_duplicate_rows_share_batches(self): + invoice = self.batch_invoice([3, 5], do_not_submit=True) + self.assertEqual(sum(row.qty for row in invoice.items), 8) + self.assertTrue(all(row.batch_no for row in invoice.items)) + + def test_throws_when_stock_is_short(self): + self.assertRaisesRegex( + frappe.ValidationError, "not enough", self.batch_invoice, [50], do_not_submit=True + ) + + def test_throws_without_source_warehouse(self): + invoice = make_sales_invoice( + update_stock=1, item_code=self.item.name, rows=[dict(qty=1, stock_qty=1)], do_not_save=True + ) + self.assertRaisesRegex(frappe.ValidationError, "source warehouse", invoice.insert) + + def test_setting_off_keeps_rows(self): + set_csf_settings(allow_batch_splitting=0) + invoice = self.batch_invoice([10], do_not_submit=True) + self.assertEqual(len(invoice.items), 1) + self.assertFalse(invoice.items[0].batch_no) + + def test_return_and_no_update_stock_are_skipped(self): + invoice = make_sales_invoice( + item_code=self.item.name, rows=[dict(qty=10, stock_qty=10)], do_not_submit=True + ) + self.assertEqual(len(invoice.items), 1) From 166d9b9201ce3ebb5de7adeda9a73bba170196b7 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:32 +0300 Subject: [PATCH 21/74] test: cover the tax template and account setup helpers --- csf_tz/tests/test_custom_api_setup_helpers.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 csf_tz/tests/test_custom_api_setup_helpers.py diff --git a/csf_tz/tests/test_custom_api_setup_helpers.py b/csf_tz/tests/test_custom_api_setup_helpers.py new file mode 100644 index 00000000..ad7e77d8 --- /dev/null +++ b/csf_tz/tests/test_custom_api_setup_helpers.py @@ -0,0 +1,105 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz.custom_api import ( + account_exists, + auto_create_account, + create_item_tax_template, + create_tax_category, + get_tax_category, + linking_tax_template, + make_salary_components_and_structure, +) +from csf_tz.tests.custom_api_helpers import COMPANY, make_test_item, set_csf_settings + + +def make_tax_template(doctype, title, tax_category): + account = frappe.get_all( + "Account", filters={"company": COMPANY, "account_type": "Tax", "is_group": 0}, pluck="name", limit=1 + )[0] + return frappe.get_doc( + doctype=doctype, + title=title, + company=COMPANY, + is_default=1, + tax_category=tax_category, + taxes=[{"charge_type": "On Net Total", "account_head": account, "description": title, "rate": 5}], + ).insert() + + +class TestGetTaxCategory(IntegrationTestCase): + """Default tax category comes from the company's default tax template.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + make_tax_template("Sales Taxes and Charges Template", "_CSF Default Sales", "_Test Tax Category 1") + make_tax_template( + "Purchase Taxes and Charges Template", "_CSF Default Purchase", "_Test Tax Category 2" + ) + + def setUp(self): + set_csf_settings(fetch_default_tax_category=1) + + def test_sales_and_purchase_documents(self): + self.assertEqual(get_tax_category("Sales Invoice", COMPANY), "_Test Tax Category 1") + self.assertEqual(get_tax_category("Purchase Order", COMPANY), "_Test Tax Category 2") + self.assertEqual(get_tax_category("Journal Entry", COMPANY), "") + + def test_disabled_setting_returns_empty(self): + set_csf_settings(fetch_default_tax_category=0) + self.assertEqual(get_tax_category("Sales Invoice", COMPANY), "") + + +class TestTanzaniaSetup(IntegrationTestCase): + """Setup helpers create accounts, tax templates, categories and payroll defaults for a company.""" + + def test_accounts_and_tax_templates(self): + self.assertFalse(account_exists("NSSF Payable")) + self.assertEqual(auto_create_account("_TC"), "Account added successfully.") + self.assertTrue(account_exists("NSSF Payable")) + self.assertEqual( + frappe.db.get_value("Account", "NSSF Payable - _TC", "parent_account"), + "Payroll Liabilities - _TC", + ) + self.assertEqual( + frappe.db.get_value("Account", "Salary Expense - _TC", "account_type"), "Expense Account" + ) + self.assertEqual(auto_create_account("_TC"), "Account added successfully.") + + self.assertEqual(create_item_tax_template("_TC"), "Tax Template added successfully.") + template = frappe.get_doc("Item Tax Template", "Tanzania VAT 18% - _TC") + self.assertEqual(template.taxes[0].tax_type, "OUTPUT VAT - 18% - _TC") + self.assertTrue(frappe.db.exists("Item Tax Template", "Zanzibar VAT Tax 0% - _TC")) + self.assertEqual(create_item_tax_template("_TC"), "Tax Template added successfully.") + + self.assertEqual(create_tax_category(), "Tax Categories added successfully.") + self.assertTrue(frappe.db.exists("Tax Category", "Non Taxable")) + self.assertEqual(create_tax_category(), "Tax Categories added successfully.") + + item = make_test_item("_CSF Tax Link Item") + frappe.db.set_value("Item", item.name, "default_tax_template", "Tanzania VAT 18% - _TC") + filters = {"default_tax_template": "Tanzania VAT 18% - _TC"} + self.assertEqual( + linking_tax_template("Item", filters, "_TC"), "Item Tax Template Linked successfully." + ) + taxes = [(row.item_tax_template, row.tax_category) for row in frappe.get_doc("Item", item.name).taxes] + self.assertEqual( + taxes, [("Tanzania VAT 18% - _TC", "Sales"), ("Tanzania Purchase VAT 18% - _TC", "Purchase")] + ) + + def test_salary_components_and_structure(self): + auto_create_account("_TC") + self.assertEqual( + make_salary_components_and_structure("_TC"), + "Salary Components and Structure are created successfully.", + ) + component = frappe.get_doc("Salary Component", "NSSF Employee") + self.assertEqual(component.accounts[0].account, "NSSF Payable - _TC") + structure = frappe.get_doc("Salary Structure", "Tanzania Mainland") + self.assertEqual(structure.docstatus, 1) + self.assertIn("PAYE Payable", [row.salary_component for row in structure.deductions]) + self.assertIsNone(make_salary_components_and_structure("_TC")) + + def test_unknown_abbreviation_throws(self): + self.assertRaisesRegex(frappe.ValidationError, "No Company", create_item_tax_template, "_NOPE") From 1f38092070a622fba048501e7266e2086aa7b01c Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:37 +0300 Subject: [PATCH 22/74] test: cover withholding tax gl entries --- .../tests/test_custom_api_withholding_tax.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 csf_tz/tests/test_custom_api_withholding_tax.py diff --git a/csf_tz/tests/test_custom_api_withholding_tax.py b/csf_tz/tests/test_custom_api_withholding_tax.py new file mode 100644 index 00000000..1e5627dd --- /dev/null +++ b/csf_tz/tests/test_custom_api_withholding_tax.py @@ -0,0 +1,139 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz.custom_api import ( + make_withholding_tax_gl_entries_for_purchase, + make_withholding_tax_gl_entries_for_sales, +) +from csf_tz.tests.custom_api_helpers import ( + COMPANY, + CUSTOMER, + SUPPLIER, + disable_db_commit, + make_purchase_invoice, + make_sales_invoice, + make_test_item, +) + + +def journal_rows(reference_type, reference_name): + return frappe.get_all( + "Journal Entry Account", + filters={"reference_type": reference_type, "reference_name": reference_name}, + fields=["parent", "account", "party", "debit_in_account_currency", "credit_in_account_currency"], + ) + + +class TestSalesWithholdingTax(IntegrationTestCase): + """on_submit hook: withholding tax receivable Journal Entry per Sales Invoice item.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_test_item("_CSF WTax Sales Item", withholding_tax_rate_on_sales=5) + + def setUp(self): + disable_db_commit(self) + frappe.db.set_value( + "Company", + COMPANY, + { + "default_withholding_receivable_account": "_Test Receivable - _TC", + "auto_create_for_sales_withholding": 1, + "auto_submit_for_sales_withholding": 0, + }, + ) + + def test_journal_entry_is_created_on_submit(self): + invoice = make_sales_invoice(item_code=self.item.name, qty=2, rate=100) + rows = journal_rows("Sales Invoice", invoice.name) + self.assertEqual(len(rows), 1) + self.assertEqual((rows[0].party, rows[0].credit_in_account_currency), (CUSTOMER, 10)) + journal = frappe.get_doc("Journal Entry", rows[0].parent) + self.assertEqual((journal.docstatus, journal.voucher_type), (0, "Contra Entry")) + self.assertEqual(journal.accounts[1].account, "_Test Receivable - _TC") + self.assertEqual(journal.accounts[1].debit_in_account_currency, 10) + invoice.reload() + self.assertEqual(invoice.items[0].withholding_tax_entry, journal.name) + self.assertEqual(invoice.items[0].csf_tz_wtax_jv_created, 1) + + def test_journal_entry_is_submitted_when_configured(self): + frappe.db.set_value("Company", COMPANY, "auto_submit_for_sales_withholding", 1) + invoice = make_sales_invoice(item_code=self.item.name, qty=2, rate=100) + journal_name = journal_rows("Sales Invoice", invoice.name)[0].parent + self.assertEqual(frappe.db.get_value("Journal Entry", journal_name, "docstatus"), 1) + + def test_skipped_when_company_flag_is_off(self): + frappe.db.set_value("Company", COMPANY, "auto_create_for_sales_withholding", 0) + invoice = make_sales_invoice(item_code=self.item.name, qty=2, rate=100) + self.assertEqual(journal_rows("Sales Invoice", invoice.name), []) + + def test_throws_without_receivable_account(self): + frappe.db.set_value("Company", COMPANY, "default_withholding_receivable_account", None) + invoice = make_sales_invoice(item_code=self.item.name, do_not_submit=True) + self.assertRaisesRegex(frappe.ValidationError, "Withholding Receivable Account", invoice.submit) + + def test_front_end_call_creates_once(self): + frappe.db.set_value("Company", COMPANY, "auto_create_for_sales_withholding", 0) + invoice = make_sales_invoice(item_code=self.item.name, qty=2, rate=100) + frappe.db.set_value("Company", COMPANY, "auto_create_for_sales_withholding", 1) + make_withholding_tax_gl_entries_for_sales(invoice.as_json(), "From Front End") + self.assertEqual(len(journal_rows("Sales Invoice", invoice.name)), 1) + invoice.reload() + make_withholding_tax_gl_entries_for_sales(invoice.as_json(), "From Front End") + self.assertEqual(len(journal_rows("Sales Invoice", invoice.name)), 1) + + +class TestPurchaseWithholdingTax(IntegrationTestCase): + """on_submit hook: withholding tax payable Journal Entry per Purchase Invoice item.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_test_item("_CSF WTax Purchase Item", witholding_tax_rate_on_purchase=2) + + def setUp(self): + disable_db_commit(self) + frappe.db.set_value( + "Company", + COMPANY, + { + "default_withholding_payable_account": "_Test Payable - _TC", + "auto_create_for_purchase_withholding": 1, + "auto_submit_for_purchase_withholding": 0, + }, + ) + + def test_journal_entry_is_created_on_submit(self): + invoice = make_purchase_invoice(item_code=self.item.name, qty=2, rate=50) + rows = journal_rows("Purchase Invoice", invoice.name) + self.assertEqual(len(rows), 1) + self.assertEqual((rows[0].party, rows[0].debit_in_account_currency), (SUPPLIER, 2)) + journal = frappe.get_doc("Journal Entry", rows[0].parent) + self.assertEqual(journal.accounts[1].account, "_Test Payable - _TC") + self.assertEqual(journal.accounts[1].credit_in_account_currency, 2) + invoice.reload() + self.assertEqual(invoice.items[0].withholding_tax_entry, journal.name) + + def test_journal_entry_is_submitted_when_configured(self): + frappe.db.set_value("Company", COMPANY, "auto_submit_for_purchase_withholding", 1) + invoice = make_purchase_invoice(item_code=self.item.name, qty=2, rate=50) + journal_name = journal_rows("Purchase Invoice", invoice.name)[0].parent + self.assertEqual(frappe.db.get_value("Journal Entry", journal_name, "docstatus"), 1) + + def test_skipped_when_company_flag_is_off(self): + frappe.db.set_value("Company", COMPANY, "auto_create_for_purchase_withholding", 0) + invoice = make_purchase_invoice(item_code=self.item.name) + self.assertEqual(journal_rows("Purchase Invoice", invoice.name), []) + + def test_throws_without_payable_account(self): + frappe.db.set_value("Company", COMPANY, "default_withholding_payable_account", None) + invoice = make_purchase_invoice(item_code=self.item.name, do_not_submit=True) + self.assertRaisesRegex(frappe.ValidationError, "Withholding Payable Account", invoice.submit) + + def test_front_end_call(self): + frappe.db.set_value("Company", COMPANY, "auto_create_for_purchase_withholding", 0) + invoice = make_purchase_invoice(item_code=self.item.name, qty=2, rate=50) + frappe.db.set_value("Company", COMPANY, "auto_create_for_purchase_withholding", 1) + make_withholding_tax_gl_entries_for_purchase(invoice.as_json(), "From Front End") + self.assertEqual(len(journal_rows("Purchase Invoice", invoice.name)), 1) From fbe2db6702dd2a90fc673c8cdd36a5fb6361dfeb Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:43 +0300 Subject: [PATCH 23/74] test: cover the write off journal entry endpoints --- csf_tz/tests/test_custom_api_write_off.py | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 csf_tz/tests/test_custom_api_write_off.py diff --git a/csf_tz/tests/test_custom_api_write_off.py b/csf_tz/tests/test_custom_api_write_off.py new file mode 100644 index 00000000..f1225cf5 --- /dev/null +++ b/csf_tz/tests/test_custom_api_write_off.py @@ -0,0 +1,92 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import nowdate + +from csf_tz.custom_api import create_write_off_jv_pe, create_write_off_jv_pi, create_write_off_jv_si +from csf_tz.tests.custom_api_helpers import ( + COMPANY, + CUSTOMER, + SUPPLIER, + disable_db_commit, + make_purchase_invoice, + make_sales_invoice, + set_csf_settings, +) + +WRITE_OFF_ACCOUNT = "Write Off - _TC" + + +def make_payment_entry(payment_type): + receive = payment_type == "Receive" + entry = frappe.get_doc( + doctype="Payment Entry", + payment_type=payment_type, + party_type="Customer" if receive else "Supplier", + party=CUSTOMER if receive else SUPPLIER, + company=COMPANY, + posting_date=nowdate(), + mode_of_payment="Cash", + paid_from="Debtors - _TC" if receive else "Cash - _TC", + paid_to="Cash - _TC" if receive else "Creditors - _TC", + paid_amount=100, + received_amount=100, + reference_no="CSF-WO", + reference_date=nowdate(), + ) + entry.insert() + entry.submit() + return entry + + +class TestWriteOffJournalEntries(IntegrationTestCase): + """Write-off Journal Entries clear invoice outstanding and payment unallocated amounts.""" + + def setUp(self): + disable_db_commit(self) + set_csf_settings(enable_write_off_jv_si=1, enable_write_off_jv_pi=1, enable_write_off_jv_pe=1) + + def test_sales_invoice_write_off(self): + invoice = make_sales_invoice(rate=100) + journal_name = create_write_off_jv_si(invoice.name, WRITE_OFF_ACCOUNT) + journal = frappe.get_doc("Journal Entry", journal_name) + self.assertEqual((journal.docstatus, journal.voucher_type), (1, "Write Off Entry")) + self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "outstanding_amount"), 0) + self.assertRaisesRegex( + frappe.ValidationError, "No outstanding", create_write_off_jv_si, invoice.name, WRITE_OFF_ACCOUNT + ) + + def test_sales_invoice_write_off_disabled(self): + set_csf_settings(enable_write_off_jv_si=0) + invoice = make_sales_invoice(rate=100) + self.assertIsNone(create_write_off_jv_si(invoice.name, WRITE_OFF_ACCOUNT)) + self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "outstanding_amount"), 100) + + def test_purchase_invoice_write_off(self): + invoice = make_purchase_invoice(rate=50) + journal_name = create_write_off_jv_pi(invoice.name, WRITE_OFF_ACCOUNT) + self.assertEqual(frappe.db.get_value("Journal Entry", journal_name, "docstatus"), 1) + self.assertEqual(frappe.db.get_value("Purchase Invoice", invoice.name, "outstanding_amount"), 0) + set_csf_settings(enable_write_off_jv_pi=0) + self.assertIsNone(create_write_off_jv_pi(invoice.name, WRITE_OFF_ACCOUNT)) + + def test_payment_entry_write_off_receive_and_pay(self): + for payment_type in ("Receive", "Pay"): + entry = make_payment_entry(payment_type) + self.assertEqual(entry.unallocated_amount, 100) + journal_name = create_write_off_jv_pe(entry.name, WRITE_OFF_ACCOUNT) + self.assertEqual(frappe.db.get_value("Journal Entry", journal_name, "docstatus"), 1) + entry.reload() + self.assertEqual(entry.unallocated_amount, 0) + self.assertEqual(entry.references[0].reference_name, journal_name) + self.assertRaisesRegex( + frappe.ValidationError, + "No unallocated", + create_write_off_jv_pe, + entry.name, + WRITE_OFF_ACCOUNT, + ) + + def test_payment_entry_write_off_disabled(self): + set_csf_settings(enable_write_off_jv_pe=0) + entry = make_payment_entry("Receive") + self.assertIsNone(create_write_off_jv_pe(entry.name, WRITE_OFF_ACCOUNT)) From c98f61ce2c39f3c1385b9f50b0c5f25dc5d97311 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:48 +0300 Subject: [PATCH 24/74] test: cover the Tanzania region, district, ward and village doctypes --- csf_tz/tests/test_geo_locations.py | 75 ++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 csf_tz/tests/test_geo_locations.py diff --git a/csf_tz/tests/test_geo_locations.py b/csf_tz/tests/test_geo_locations.py new file mode 100644 index 00000000..6baf0315 --- /dev/null +++ b/csf_tz/tests/test_geo_locations.py @@ -0,0 +1,75 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz.csf_tz.doctype.tz_district.tz_district import TZDistrict +from csf_tz.csf_tz.doctype.tz_region.tz_region import TZRegion +from csf_tz.csf_tz.doctype.tz_village.tz_village import TZVillage +from csf_tz.csf_tz.doctype.tz_ward.tz_ward import TZWard + +REGION = "_Test Geo Region" + + +def make_region(region=REGION): + if frappe.db.exists("TZ Region", region): + return frappe.get_doc("TZ Region", region) + return frappe.get_doc({"doctype": "TZ Region", "region": region}).insert() + + +def make_district(district, region=REGION): + name = frappe.db.get_value("TZ District", {"district": district}) + if name: + return frappe.get_doc("TZ District", name) + return frappe.get_doc({"doctype": "TZ District", "district": district, "region": region}).insert() + + +class TestGeoLocations(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.region = make_region() + cls.district = make_district("_Test Geo District") + + def test_region_is_named_by_region_field(self): + self.assertIsInstance(self.region, TZRegion) + self.assertEqual(self.region.name, REGION) + + def test_region_must_be_unique(self): + with self.assertRaises(frappe.DuplicateEntryError): + frappe.get_doc({"doctype": "TZ Region", "region": REGION}).insert() + + def test_district_uses_expression_naming_and_links_region(self): + self.assertIsInstance(self.district, TZDistrict) + self.assertTrue(self.district.name.startswith("D-")) + self.assertEqual(self.district.region, REGION) + + def test_district_requires_region(self): + with self.assertRaises(frappe.MandatoryError): + frappe.get_doc({"doctype": "TZ District", "district": "_Test Geo Orphan District"}).insert() + + def test_district_rejects_unknown_region(self): + with self.assertRaises(frappe.LinkValidationError): + frappe.get_doc( + {"doctype": "TZ District", "district": "_Test Geo Bad District", "region": "No Such Region"} + ).insert() + + def test_ward_and_village_chain(self): + ward = frappe.get_doc( + {"doctype": "TZ Ward", "ward": "_Test Geo Ward", "district": self.district.name} + ).insert() + self.assertIsInstance(ward, TZWard) + self.assertTrue(ward.name.startswith("W-")) + + village = frappe.get_doc( + {"doctype": "TZ Village", "village": "_Test Geo Village", "ward": ward.name, "postcode": "11101"} + ).insert() + self.assertIsInstance(village, TZVillage) + self.assertTrue(village.name.startswith("V-")) + self.assertEqual(frappe.db.get_value("TZ Village", village.name, "postcode"), "11101") + + def test_village_requires_ward(self): + with self.assertRaises(frappe.MandatoryError): + frappe.get_doc({"doctype": "TZ Village", "village": "_Test Geo Orphan Village"}).insert() + + def test_region_cannot_be_deleted_while_linked(self): + with self.assertRaises(frappe.LinkExistsError): + frappe.delete_doc("TZ Region", REGION) From 5b7397285321067671aa10cf70647ec20f76533c Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:53 +0300 Subject: [PATCH 25/74] test: cover overtime calculation on attendance --- csf_tz/tests/test_hr_attendance_overtime.py | 191 ++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 csf_tz/tests/test_hr_attendance_overtime.py diff --git a/csf_tz/tests/test_hr_attendance_overtime.py b/csf_tz/tests/test_hr_attendance_overtime.py new file mode 100644 index 00000000..add282d2 --- /dev/null +++ b/csf_tz/tests/test_hr_attendance_overtime.py @@ -0,0 +1,191 @@ +from datetime import timedelta + +import frappe +from frappe.utils import ( + add_days, + get_first_day, + get_weekday, + get_year_ending, + get_year_start, + getdate, + nowdate, +) + +from csf_tz.csftz_hooks import attendance as hooks +from csf_tz.tests.hr_payroll_fixtures import ( + COMPANY, + HRPayrollTestCase, + make_payroll_employee, + set_csf_tz_settings, +) + +SHIFT = "_Test CSF Overtime Shift" +HOLIDAY_LIST = "_Test CSF Overtime Holidays" + + +def seconds(value): + if value in (None, ""): + return None + if isinstance(value, timedelta): + return int(value.total_seconds()) + value = str(value).split(" ")[-1] + hours, minutes, secs = (int(part) for part in value.split(":")) + return hours * 3600 + minutes * 60 + secs + + +def make_holiday_list(holiday_date): + frappe.delete_doc_if_exists("Holiday List", HOLIDAY_LIST, force=True) + return frappe.get_doc( + { + "doctype": "Holiday List", + "holiday_list_name": HOLIDAY_LIST, + "from_date": get_year_start(nowdate()), + "to_date": get_year_ending(nowdate()), + "holidays": [{"holiday_date": holiday_date, "description": "Test holiday"}], + } + ).insert() + + +def make_shift_type(): + frappe.delete_doc_if_exists("Shift Type", SHIFT, force=True) + thresholds = { + f"{day}_threshold": "08:00:00" + for day in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday") + } + return frappe.get_doc( + { + "doctype": "Shift Type", + "__newname": SHIFT, + "start_time": "08:00:00", + "end_time": "17:00:00", + "enable_late_entry_marking": 1, + "late_entry_grace_period": 15, + "enable_early_exit_marking": 1, + "early_exit_grace_period": 15, + "overtime_holiday": HOLIDAY_LIST, + **thresholds, + } + ).insert() + + +class TestAttendanceOvertime(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee = make_payroll_employee("csf_overtime@example.com", overtime_applicable=1) + cls.holiday = cls.pick_day(offset=0) + cls.workday = cls.pick_day(offset=1) + make_holiday_list(cls.holiday) + make_shift_type() + + @classmethod + def pick_day(cls, offset): + day = add_days(get_first_day(nowdate()), offset) + while get_weekday(getdate(day)) == "Sunday": + day = add_days(day, 1) + return day + + def setUp(self): + super().setUp() + set_csf_tz_settings(enable_overtime_calculation=1) + + def make_attendance(self, in_time, out_time, date=None, status="Present", **values): + date = date or self.workday + doc = frappe.get_doc( + { + "doctype": "Attendance", + "employee": self.employee, + "company": COMPANY, + "attendance_date": date, + "status": status, + "shift": SHIFT, + "in_time": f"{date} {in_time}", + "out_time": f"{date} {out_time}", + **values, + } + ) + return doc.insert() + + def test_disabled_setting_leaves_fields_empty(self): + set_csf_tz_settings(enable_overtime_calculation=0) + doc = self.make_attendance("07:30:00", "18:00:00") + self.assertIsNone(doc.eligible_working_hours) + self.assertIsNone(doc.excess_overtime_normal) + + def test_non_present_status_resets_fields(self): + doc = self.make_attendance("07:30:00", "18:00:00", status="Absent") + self.assertEqual(seconds(doc.eligible_working_hours), 0) + self.assertEqual(seconds(doc.eligible_overtime_normal), 0) + self.assertEqual(seconds(doc.excess_overtime_holiday), 0) + + def test_employee_without_overtime_flag_resets_fields(self): + frappe.db.set_value("Employee", self.employee, "overtime_applicable", 0) + doc = self.make_attendance("07:30:00", "18:00:00") + self.assertEqual(doc.overtime_applicable, 0) + self.assertEqual(seconds(doc.eligible_working_hours), 0) + + def test_normal_day_overtime_and_excess(self): + doc = self.make_attendance("07:30:00", "18:00:00") + self.assertEqual(seconds(doc.start_time), 8 * 3600) + self.assertEqual(seconds(doc.eligible_working_hours), 9 * 3600) + self.assertEqual(seconds(doc.eligible_overtime_normal), 1 * 3600) + self.assertEqual(seconds(doc.excess_overtime_normal), 90 * 60) + self.assertEqual(seconds(doc.eligible_overtime_holiday), 0) + self.assertEqual(seconds(doc.excess_overtime_holiday), 0) + self.assertEqual( + frappe.db.get_value("Attendance", doc.name, "excess_overtime_normal"), timedelta(minutes=90) + ) + + def test_grace_periods_round_to_shift_times(self): + doc = self.make_attendance("08:10:00", "16:50:00") + self.assertEqual(seconds(doc.eligible_working_hours), 9 * 3600) + self.assertEqual(seconds(doc.eligible_overtime_normal), 1 * 3600) + self.assertEqual(seconds(doc.excess_overtime_normal), 0) + + def test_beyond_grace_uses_actual_times(self): + doc = self.make_attendance("08:30:00", "16:00:00") + self.assertEqual(seconds(doc.eligible_working_hours), 7 * 3600 + 30 * 60) + self.assertEqual(seconds(doc.eligible_overtime_normal), 0) + self.assertEqual(seconds(doc.excess_overtime_normal), 0) + + def test_holiday_counts_all_hours_as_holiday_overtime(self): + doc = self.make_attendance("07:30:00", "18:00:00", date=self.holiday) + self.assertEqual(seconds(doc.eligible_overtime_holiday), 9 * 3600) + self.assertEqual(seconds(doc.excess_overtime_holiday), 90 * 60) + self.assertEqual(seconds(doc.eligible_overtime_normal), 0) + self.assertEqual(seconds(doc.excess_overtime_normal), 0) + + def test_on_approval_overtime_moves_hours_to_excess(self): + frappe.db.set_value("Employee", self.employee, "on_approval_overtime", 1) + doc = self.make_attendance("07:30:00", "18:00:00") + self.assertEqual(doc.on_approval_overtime, 1) + self.assertEqual(seconds(doc.eligible_overtime_normal), 0) + self.assertEqual(seconds(doc.excess_overtime_normal), 2 * 3600 + 30 * 60) + self.assertEqual(seconds(doc.eligible_overtime_holiday), 0) + self.assertEqual(seconds(doc.excess_overtime_holiday), 0) + self.assertEqual( + frappe.db.get_value("Attendance", doc.name, "excess_overtime_normal"), timedelta(minutes=150) + ) + + def test_on_approval_overtime_on_holiday(self): + frappe.db.set_value("Employee", self.employee, "on_approval_overtime", 1) + doc = self.make_attendance("07:30:00", "18:00:00", date=self.holiday) + self.assertEqual(seconds(doc.excess_overtime_holiday), 10 * 3600 + 30 * 60) + self.assertEqual(seconds(doc.excess_overtime_normal), 0) + + def test_shift_without_overtime_holiday_throws(self): + frappe.db.set_value("Shift Type", SHIFT, "overtime_holiday", None) + self.assertRaises(frappe.ValidationError, self.make_attendance, "07:30:00", "18:00:00") + + def test_weekday_threshold_lookup(self): + shift_type = frappe.get_doc("Shift Type", SHIFT) + shift_type.friday_threshold = "06:00:00" + friday = getdate(self.workday) + while get_weekday(friday) != "Friday": + friday = add_days(friday, 1) + self.assertEqual(seconds(hooks.get_weekday_threshold(shift_type, friday)), 6 * 3600) + self.assertEqual(seconds(hooks.get_weekday_threshold(shift_type, add_days(friday, 1))), 8 * 3600) + + def test_holiday_status(self): + self.assertTrue(hooks.get_holiday_status(HOLIDAY_LIST, self.holiday)) + self.assertFalse(hooks.get_holiday_status(HOLIDAY_LIST, self.workday)) From 12a62378304f60aa775ebe405be98b0abe27fea8 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:56:58 +0300 Subject: [PATCH 26/74] test: cover the employee advance payment hook --- csf_tz/tests/test_hr_employee_advance.py | 76 ++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 csf_tz/tests/test_hr_employee_advance.py diff --git a/csf_tz/tests/test_hr_employee_advance.py b/csf_tz/tests/test_hr_employee_advance.py new file mode 100644 index 00000000..9123ee9d --- /dev/null +++ b/csf_tz/tests/test_hr_employee_advance.py @@ -0,0 +1,76 @@ +from unittest.mock import patch + +import frappe +from hrms.hr.doctype.employee_advance.test_employee_advance import make_employee_advance + +from csf_tz.csftz_hooks import employee_advance_payment_and_expense as hooks +from csf_tz.tests.hr_payroll_fixtures import COMPANY, HRPayrollTestCase, make_payroll_employee + + +def disable_payment_reference_fetch_fields(): + """csf_tz fetch_from fields on Payment Entry Reference read from_date/to_date, which Employee Advance lacks.""" + for fieldname in ("start_date", "end_date"): + frappe.db.set_value("Custom Field", f"Payment Entry Reference-{fieldname}", "fetch_from", None) + frappe.clear_cache(doctype="Payment Entry Reference") + + +def make_travel_request(employee): + if not frappe.db.exists("Purpose of Travel", "_Test CSF Purpose"): + frappe.get_doc({"doctype": "Purpose of Travel", "purpose_of_travel": "_Test CSF Purpose"}).insert() + return frappe.get_doc( + { + "doctype": "Travel Request", + "employee": employee, + "travel_type": "Domestic", + "purpose_of_travel": "_Test CSF Purpose", + "company": COMPANY, + } + ).insert() + + +class TestEmployeeAdvancePayment(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee = make_payroll_employee("csf_employee_advance@example.com") + frappe.db.set_value("Account", "_Test Employee Advance - _TC", "account_type", "Receivable") + cls.travel_request = make_travel_request(cls.employee) + disable_payment_reference_fetch_fields() + + def payment_entries(self, advance): + return frappe.get_all( + "Payment Entry", + filters={"reference_no": advance.name, "docstatus": ["!=", 2]}, + fields=["name", "paid_amount", "party", "party_type", "docstatus", "payment_type"], + ) + + def test_submit_with_travel_request_creates_payment_entry(self): + advance = make_employee_advance(self.employee, {"travel_request_ref": self.travel_request.name}) + entries = self.payment_entries(advance) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0].paid_amount, 1000) + self.assertEqual(entries[0].party, self.employee) + self.assertEqual(entries[0].party_type, "Employee") + self.assertEqual(entries[0].payment_type, "Pay") + self.assertEqual(entries[0].docstatus, 0) + + def test_submit_without_travel_request_does_nothing(self): + advance = make_employee_advance(self.employee) + self.assertEqual(self.payment_entries(advance), []) + + def test_existing_payment_entry_is_not_duplicated(self): + advance = make_employee_advance(self.employee, {"travel_request_ref": self.travel_request.name}) + with patch.object(frappe, "msgprint") as msgprint: + hooks.execute(advance, "on_submit") + self.assertIn("already exists", msgprint.call_args.args[0]) + self.assertEqual(len(self.payment_entries(advance)), 1) + + def test_payment_entry_failure_is_raised(self): + with patch.object(hooks, "create_payment_entry", side_effect=Exception("boom")): + self.assertRaisesRegex( + frappe.ValidationError, + "boom", + make_employee_advance, + self.employee, + {"travel_request_ref": self.travel_request.name}, + ) From 464374550a03bf3f9ecf35aab7b142a73fb587f9 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:03 +0300 Subject: [PATCH 27/74] test: cover employee checkin validation --- csf_tz/tests/test_hr_employee_checkin.py | 89 ++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 csf_tz/tests/test_hr_employee_checkin.py diff --git a/csf_tz/tests/test_hr_employee_checkin.py b/csf_tz/tests/test_hr_employee_checkin.py new file mode 100644 index 00000000..32d44c0e --- /dev/null +++ b/csf_tz/tests/test_hr_employee_checkin.py @@ -0,0 +1,89 @@ +from datetime import datetime, timedelta +from unittest.mock import patch + +import frappe +from frappe.utils import add_days, get_time, getdate +from hrms.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type + +from csf_tz.csftz_hooks import employee_checkin as hooks +from csf_tz.tests.hr_payroll_fixtures import HRPayrollTestCase, make_payroll_employee, set_csf_tz_settings + +SHIFT = "_Test CSF Checkin Shift" +DEFAULT_SHIFT = "_Test CSF Default Shift" + + +def make_checkin(employee, time, log_type="IN"): + return frappe.get_doc( + {"doctype": "Employee Checkin", "employee": employee, "time": time, "log_type": log_type} + ).insert() + + +class TestEmployeeCheckin(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.date = getdate() + cls.employee = make_payroll_employee("csf_checkin@example.com") + cls.default_employee = make_payroll_employee("csf_checkin_default@example.com") + cls.shift_type = setup_shift_type(shift_type=SHIFT) + cls.default_shift = setup_shift_type( + shift_type=DEFAULT_SHIFT, start_time="14:00:00", end_time="18:00:00" + ) + make_shift_assignment(SHIFT, cls.employee, cls.date) + frappe.db.set_value("Employee", cls.default_employee, "default_shift", DEFAULT_SHIFT) + + def setUp(self): + super().setUp() + set_csf_tz_settings(override_fetch_shift_details=1) + + def at(self, time, date=None): + return datetime.combine(date or self.date, get_time(time)) + + def test_checkin_inside_shift_sets_shift_details(self): + log = make_checkin(self.employee, self.at("08:45:00")) + self.assertEqual(log.shift, SHIFT) + self.assertEqual(log.shift_start, self.at("08:00:00")) + self.assertEqual(log.shift_end, self.at("12:00:00")) + self.assertEqual(log.shift_actual_start, self.at("07:00:00")) + self.assertEqual(log.shift_actual_end, self.at("13:00:00")) + + def test_checkin_outside_shift_clears_shift(self): + log = make_checkin(self.employee, self.at("13:01:00")) + self.assertIsNone(log.shift) + + def test_duplicate_log_rejected(self): + make_checkin(self.employee, self.at("08:45:00")) + self.assertRaises(frappe.ValidationError, make_checkin, self.employee, self.at("08:45:00")) + + def test_inactive_employee_rejected(self): + frappe.db.set_value("Employee", self.employee, "status", "Inactive") + self.assertRaises(frappe.ValidationError, make_checkin, self.employee, self.at("08:45:00")) + + def test_override_disabled_skips_csf_logic(self): + set_csf_tz_settings(override_fetch_shift_details=0) + with patch.object(hooks, "get_employee_shift_timings") as timings: + log = make_checkin(self.employee, self.at("08:45:00")) + timings.assert_not_called() + self.assertEqual(log.shift, SHIFT) + + def test_get_shifts_for_date(self): + shifts = hooks.get_shifts_for_date(self.employee, self.at("08:00:00")) + self.assertEqual([shift.shift_type for shift in shifts], [SHIFT]) + self.assertEqual( + hooks.get_shifts_for_date(self.employee, self.at("08:00:00", add_days(self.date, -1))), [] + ) + + def test_get_employee_shift_uses_default_shift(self): + timestamp = self.at("15:00:00") + self.assertEqual(hooks.get_employee_shift(self.default_employee, timestamp), {}) + shift = hooks.get_employee_shift(self.default_employee, timestamp, consider_default_shift=True) + self.assertEqual(shift.shift_type.name, DEFAULT_SHIFT) + self.assertEqual(shift.start_datetime, self.at("14:00:00")) + + def test_get_employee_shift_timings(self): + prev_shift, current, next_shift = hooks.get_employee_shift_timings( + self.employee, self.at("08:45:00"), True + ) + self.assertEqual(current.shift_type.name, SHIFT) + self.assertEqual(next_shift.start_datetime, self.at("08:00:00") + timedelta(days=1)) + self.assertFalse(prev_shift) From 0a49bd85ee8f25e151f5e7efe9ea3daac154d773 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:08 +0300 Subject: [PATCH 28/74] test: cover the employee contact QR endpoint --- csf_tz/tests/test_hr_employee_contact_qr.py | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 csf_tz/tests/test_hr_employee_contact_qr.py diff --git a/csf_tz/tests/test_hr_employee_contact_qr.py b/csf_tz/tests/test_hr_employee_contact_qr.py new file mode 100644 index 00000000..bf8dbe38 --- /dev/null +++ b/csf_tz/tests/test_hr_employee_contact_qr.py @@ -0,0 +1,30 @@ +import base64 + +import frappe + +from csf_tz.csftz_hooks.employee_contact_qr import generate_contact_qr +from csf_tz.tests.hr_payroll_fixtures import HRPayrollTestCase, make_payroll_employee + + +class TestEmployeeContactQR(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee = make_payroll_employee("csf_contact_qr@example.com", cell_number="0711111111") + + def test_generates_png_qr_code(self): + image = base64.b64decode(generate_contact_qr(self.employee)) + self.assertTrue(image.startswith(b"\x89PNG")) + + def test_requires_phone_or_email(self): + frappe.db.set_value( + "Employee", self.employee, {"cell_number": "", "company_email": "", "personal_email": ""} + ) + self.assertRaises(frappe.ValidationError, generate_contact_qr, self.employee) + + def test_denies_unprivileged_user(self): + frappe.set_user("test@example.com") + try: + self.assertRaises(frappe.PermissionError, generate_contact_qr, self.employee) + finally: + frappe.set_user("Administrator") From df0e7ca6fbcba67f7ae145dbccf8811a5234ccef Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:14 +0300 Subject: [PATCH 29/74] test: cover leave encashment validation --- csf_tz/tests/test_hr_leave_encashment.py | 132 +++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 csf_tz/tests/test_hr_leave_encashment.py diff --git a/csf_tz/tests/test_hr_leave_encashment.py b/csf_tz/tests/test_hr_leave_encashment.py new file mode 100644 index 00000000..efb02b1d --- /dev/null +++ b/csf_tz/tests/test_hr_leave_encashment.py @@ -0,0 +1,132 @@ +import frappe +from frappe.utils import get_year_ending, get_year_start, getdate +from hrms.hr.doctype.leave_encashment.test_leave_encashment import create_leave_encashment +from hrms.hr.doctype.leave_period.test_leave_period import create_leave_period +from hrms.hr.doctype.leave_policy.test_leave_policy import create_leave_policy +from hrms.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( + create_assignment_for_multiple_employees, +) + +from csf_tz.csftz_hooks import leave_encashment as hooks +from csf_tz.tests.hr_payroll_fixtures import ( + HRPayrollTestCase, + assign_salary_structure, + make_payroll_employee, + setup_payroll_master_data, +) + +LEAVE_TYPE = "_Test Leave Type Encashment" + + +class TestLeaveEncashment(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + setup_payroll_master_data() + cls.employee = make_payroll_employee("csf_leave_encashment@example.com") + year_start, year_end = getdate(get_year_start(getdate())), getdate(get_year_ending(getdate())) + cls.leave_period = create_leave_period(year_start, year_end, "_Test Company") + frappe.db.set_value( + "Leave Type", + LEAVE_TYPE, + { + "earning_component": "Leave Encashment", + "deduction_component": "Professional Tax", + "non_encashable_leaves": 5, + }, + ) + leave_policy = create_leave_policy(leave_type=LEAVE_TYPE, annual_allocation=10) + leave_policy.submit() + create_assignment_for_multiple_employees( + [cls.employee], + frappe._dict( + assignment_based_on="Leave Period", + leave_policy=leave_policy.name, + leave_period=cls.leave_period.name, + ), + ) + assign_salary_structure( + cls.employee, "_Test CSF Encashment Structure", leave_encashment_amount_per_day=50 + ) + + def make_encashment(self, **values): + args = { + "employee": self.employee, + "leave_type": LEAVE_TYPE, + "leave_period": self.leave_period.name, + "encashment_date": self.leave_period.to_date, + "currency": "INR", + } + args.update(values) + return create_leave_encashment(**args) + + def additional_salary(self, encashment): + return frappe.get_doc("Additional Salary", {"ref_docname": encashment.name}) + + def test_positive_days_select_earning(self): + encashment = self.make_encashment() + self.assertEqual(encashment.encashment_days, 5) + self.assertEqual(encashment.encashment_amount, 250) + self.assertEqual((encashment.is_earning, encashment.is_deduction), (1, 0)) + + encashment.submit() + additional_salary = self.additional_salary(encashment) + self.assertEqual(additional_salary.type, "Earning") + self.assertEqual(additional_salary.salary_component, "Leave Encashment") + self.assertEqual(additional_salary.amount, 250) + self.assertEqual(additional_salary.docstatus, 1) + self.assertEqual(encashment.additional_salary, additional_salary.name) + + def test_negative_days_select_deduction(self): + encashment = self.make_encashment(encashment_days=-2) + self.assertEqual(encashment.encashment_amount, -100) + self.assertEqual((encashment.is_earning, encashment.is_deduction), (0, 1)) + + encashment.submit() + additional_salary = self.additional_salary(encashment) + self.assertEqual(additional_salary.type, "Deduction") + self.assertEqual(additional_salary.salary_component, "Professional Tax") + self.assertEqual(additional_salary.amount, 100) + + encashment.cancel() + self.assertEqual(frappe.db.get_value("Additional Salary", additional_salary.name, "docstatus"), 2) + + def test_deduction_requires_deduction_component(self): + frappe.db.set_value("Leave Type", LEAVE_TYPE, "deduction_component", None) + encashment = self.make_encashment(encashment_days=-2) + self.assertRaises(frappe.ValidationError, encashment.submit) + + def test_both_flags_rejected(self): + encashment = self.make_encashment() + encashment.is_earning = encashment.is_deduction = 1 + encashment.encashment_days = encashment.encashment_amount = 0 + self.assertRaises(frappe.ValidationError, hooks.validate_flags, encashment) + + def test_before_submit_rejects_invalid_amounts(self): + encashment = self.make_encashment() + encashment.encashment_amount = None + self.assertRaises(frappe.ValidationError, encashment.before_submit) + + encashment.encashment_amount = 0 + encashment.is_earning = 1 + self.assertRaises(frappe.ValidationError, encashment.before_submit) + + encashment.encashment_days = -2 + encashment.encashment_amount = 100 + self.assertRaises(frappe.ValidationError, encashment.before_submit) + self.assertEqual((encashment.is_earning, encashment.is_deduction), (0, 1)) + + def test_before_submit_requires_selection(self): + encashment = self.make_encashment() + encashment.is_earning = encashment.is_deduction = 0 + encashment.encashment_days = encashment.encashment_amount = 0 + self.assertRaises(frappe.ValidationError, hooks.ensure_selection_before_submit, encashment) + + def test_get_salary_component_sources(self): + encashment = self.make_encashment() + self.assertEqual(hooks._get_salary_component(encashment, "deduction")[0], "Professional Tax") + self.assertEqual(hooks._get_salary_component(encashment, "earning")[0], "Leave Encashment") + frappe.db.set_value("Leave Type", LEAVE_TYPE, "earning_component", None) + component, source = hooks._get_salary_component(encashment, "earning") + self.assertIsNone(component) + self.assertIn(LEAVE_TYPE, source) From fcff6e51c01f2bb24f9dd4385e32c955273dfb00 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:19 +0300 Subject: [PATCH 30/74] test: cover additional salary journals and hourly base pay --- .../test_hr_payroll_additional_salary.py | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 csf_tz/tests/test_hr_payroll_additional_salary.py diff --git a/csf_tz/tests/test_hr_payroll_additional_salary.py b/csf_tz/tests/test_hr_payroll_additional_salary.py new file mode 100644 index 00000000..f3f7e4bd --- /dev/null +++ b/csf_tz/tests/test_hr_payroll_additional_salary.py @@ -0,0 +1,199 @@ +import frappe +from frappe.utils import add_days, add_months, get_first_day, getdate, nowdate +from hrms.payroll.doctype.salary_slip.test_salary_slip import set_salary_component_account + +from csf_tz.csftz_hooks import additional_salary as hooks +from csf_tz.tests.hr_payroll_fixtures import ( + COMPANY, + HRPayrollTestCase, + assign_salary_structure, + make_payroll_employee, + set_csf_tz_settings, + setup_payroll_master_data, +) + +HOURLY_COMPONENT = "_Test CSF Hourly Component" +CASH_COMPONENT = "_Test CSF Cash Component" +CASH_COMPONENT_NO_ACCOUNT = "_Test CSF Cash Component No Account" + + +def make_component(name, **values): + if frappe.db.exists("Salary Component", name): + frappe.delete_doc("Salary Component", name, force=True) + component = frappe.new_doc("Salary Component") + component.salary_component = name + component.salary_component_abbr = name.replace(" ", "")[:8] + component.type = "Earning" + component.update(values) + component.insert() + return component + + +class TestAdditionalSalary(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + setup_payroll_master_data() + cls.employee = make_payroll_employee("csf_additional_salary@example.com") + assign_salary_structure( + cls.employee, + "_Test CSF Additional Salary Structure", + from_date=add_months(get_first_day(nowdate()), -3), + ) + make_component(HOURLY_COMPONENT, based_on_hourly_rate=1, hourly_rate=150) + set_salary_component_account(make_component(CASH_COMPONENT, create_cash_journal=1)) + make_component(CASH_COMPONENT_NO_ACCOUNT, create_cash_journal=1) + + def setUp(self): + super().setUp() + set_csf_tz_settings( + working_hours_per_month=200, + default_account_for_additional_component_cash_journal="Cash - _TC", + ) + + def make_additional_salary(self, component="HRA", amount=100, **values): + doc = frappe.new_doc("Additional Salary") + doc.employee = self.employee + doc.company = COMPANY + doc.salary_component = component + doc.amount = amount + doc.payroll_date = nowdate() + doc.update(values) + return doc.insert() + + def test_negative_amount_rejected_by_default(self): + self.assertRaises(frappe.ValidationError, self.make_additional_salary, amount=-100) + + def test_negative_amount_allowed_for_flagged_earning(self): + frappe.db.set_value("Salary Component", "HRA", "allow_negative", 1) + doc = self.make_additional_salary(amount=-100) + self.assertEqual(doc.amount, -100) + + def test_negative_amount_rejected_for_deduction_even_if_flagged(self): + frappe.db.set_value("Salary Component", "Professional Tax", "allow_negative", 1) + self.assertRaises( + frappe.ValidationError, self.make_additional_salary, component="Professional Tax", amount=-5 + ) + + def test_duplicate_overwrite_is_rejected(self): + self.make_additional_salary(overwrite_salary_structure_amount=1).submit() + self.assertRaises( + frappe.ValidationError, self.make_additional_salary, overwrite_salary_structure_amount=1 + ) + + def test_hourly_rate_sets_amount(self): + doc = self.make_additional_salary(component=HOURLY_COMPONENT, amount=0, no_of_hours=10) + self.assertEqual(doc.based_on_hourly_rate, 1) + self.assertEqual(doc.hourly_rate, 150) + self.assertEqual(doc.amount, 3750) + + def test_hourly_rate_requires_working_hours_setting(self): + set_csf_tz_settings(working_hours_per_month=0) + self.assertRaises( + frappe.ValidationError, self.make_additional_salary, component=HOURLY_COMPONENT, no_of_hours=10 + ) + + def test_get_employee_base_salary_in_hours(self): + result = hooks.get_employee_base_salary_in_hours(self.employee, nowdate()) + self.assertEqual(result["base_salary_in_hours"], 250) + + def test_cash_journal_created_on_submit(self): + doc = self.make_additional_salary(component=CASH_COMPONENT, amount=500) + doc.submit() + + journal_entry = frappe.get_doc( + "Journal Entry", {"referance_doctype": "Additional Salary", "referance_docname": doc.name} + ) + self.assertEqual(journal_entry.voucher_type, "Cash Entry") + self.assertEqual(journal_entry.total_debit, 500) + self.assertEqual(journal_entry.docstatus, 0) + accounts = { + row.account: (row.debit_in_account_currency, row.credit_in_account_currency) + for row in journal_entry.accounts + } + self.assertEqual(accounts["Cash - _TC"], (0, 500)) + self.assertEqual(accounts["Salary - _TC"], (500, 0)) + + def test_cash_journal_requires_default_account(self): + set_csf_tz_settings(default_account_for_additional_component_cash_journal=None) + doc = self.make_additional_salary(component=CASH_COMPONENT, amount=500) + self.assertRaises(frappe.ValidationError, doc.submit) + + def test_cash_journal_requires_component_account(self): + doc = self.make_additional_salary(component=CASH_COMPONENT_NO_ACCOUNT, amount=500) + self.assertRaises(frappe.ValidationError, doc.submit) + + def test_submit_updates_source_last_transaction_amount(self): + source = self.make_additional_salary(amount=100, auto_repeat_frequency="Monthly") + source.submit() + child = self.make_additional_salary(amount=120, auto_created_based_on=source.name) + child.submit() + self.assertEqual( + frappe.db.get_value("Additional Salary", source.name, "last_transaction_amount"), 120 + ) + + def auto_created_from(self, source): + return frappe.get_all( + "Additional Salary", + filters={"auto_created_based_on": source}, + fields=["name", "payroll_date", "amount", "auto_repeat_frequency", "docstatus"], + ) + + def test_generate_monthly_records(self): + source = self.make_additional_salary( + amount=300, + payroll_date=add_months(nowdate(), -1), + auto_repeat_frequency="Monthly", + auto_repeat_end_date=add_months(nowdate(), 6), + ) + source.submit() + + hooks.generate_additional_salary_records() + + created = self.auto_created_from(source.name) + self.assertEqual(len(created), 1) + self.assertEqual(created[0].payroll_date, getdate(nowdate())) + self.assertEqual(created[0].amount, 300) + self.assertEqual(created[0].auto_repeat_frequency, "None") + self.assertEqual(created[0].docstatus, 0) + self.assertEqual( + frappe.db.get_value("Additional Salary", source.name, "last_transaction_date"), getdate(nowdate()) + ) + + hooks.generate_additional_salary_records() + self.assertEqual(len(self.auto_created_from(source.name)), 1) + + def test_generate_weekly_records(self): + source = self.make_additional_salary( + amount=50, + payroll_date=add_days(nowdate(), -7), + auto_repeat_frequency="Weekly", + auto_repeat_end_date=add_months(nowdate(), 1), + ) + source.submit() + hooks.generate_additional_salary_records() + created = self.auto_created_from(source.name) + self.assertEqual(len(created), 1) + self.assertEqual(created[0].payroll_date, getdate(nowdate())) + + def test_generate_skips_future_records(self): + source = self.make_additional_salary( + amount=50, + payroll_date=nowdate(), + auto_repeat_frequency="Monthly", + auto_repeat_end_date=add_months(nowdate(), 6), + ) + source.submit() + hooks.generate_additional_salary_records() + self.assertEqual(self.auto_created_from(source.name), []) + + def test_generate_rejects_unknown_frequency(self): + source = self.make_additional_salary( + amount=50, + payroll_date=add_months(nowdate(), -1), + auto_repeat_frequency="Monthly", + auto_repeat_end_date=add_months(nowdate(), 6), + ) + source.submit() + frappe.db.set_value("Additional Salary", source.name, "auto_repeat_frequency", "Daily") + self.assertRaises(frappe.ValidationError, hooks.generate_additional_salary_records) From 376fc2a6799d8751ae793a7b1d92591b54e0327d Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:24 +0300 Subject: [PATCH 31/74] test: cover the payroll entry hooks and summaries --- csf_tz/tests/test_hr_payroll_entry.py | 204 ++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 csf_tz/tests/test_hr_payroll_entry.py diff --git a/csf_tz/tests/test_hr_payroll_entry.py b/csf_tz/tests/test_hr_payroll_entry.py new file mode 100644 index 00000000..383d185a --- /dev/null +++ b/csf_tz/tests/test_hr_payroll_entry.py @@ -0,0 +1,204 @@ +from io import BytesIO +from unittest.mock import patch + +import frappe +from PyPDF3 import PdfFileWriter + +from csf_tz.csftz_hooks import payroll +from csf_tz.tests.hr_payroll_fixtures import ( + HRPayrollTestCase, + assign_salary_structure, + get_slips, + make_department, + make_payroll_employee, + make_test_payroll_entry, + set_csf_tz_settings, + setup_payroll_master_data, +) + + +def blank_pdf(): + writer = PdfFileWriter() + writer.addBlankPage(width=100, height=100) + buffer = BytesIO() + writer.write(buffer) + return buffer.getvalue() + + +class TestPayrollEntryHooks(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + setup_payroll_master_data() + cls.department = make_department("_Test CSF Payroll") + cls.employee = make_payroll_employee("csf_payroll_entry@example.com", department=cls.department) + assign_salary_structure(cls.employee, "_Test CSF Payroll Structure") + + def setUp(self): + super().setUp() + set_csf_tz_settings(enable_payroll_approval=0) + + def test_payroll_approval_flag_is_set_on_insert(self): + set_csf_tz_settings(enable_payroll_approval=1) + payroll_entry = make_test_payroll_entry(self.department) + self.assertEqual(payroll_entry.has_payroll_approval, 1) + + slips = get_slips(payroll_entry.name) + self.assertEqual(len(slips), 1) + self.assertEqual(frappe.db.get_value("Salary Slip", slips[0], "has_payroll_approval"), 1) + + def test_payroll_approval_flag_is_not_set_when_disabled(self): + payroll_entry = make_test_payroll_entry(self.department) + self.assertEqual(payroll_entry.has_payroll_approval, 0) + slip = get_slips(payroll_entry.name)[0] + self.assertEqual(frappe.db.get_value("Salary Slip", slip, "has_payroll_approval"), 0) + + def test_update_slip_recomputes_draft_slip(self): + payroll_entry = make_test_payroll_entry(self.department) + slip = get_slips(payroll_entry.name)[0] + gross_pay = frappe.db.get_value("Salary Slip", slip, "gross_pay") + frappe.db.set_value("Salary Slip", slip, "gross_pay", 1) + + self.assertEqual(payroll.update_slip(slip), "updated") + self.assertEqual(frappe.db.get_value("Salary Slip", slip, "gross_pay"), gross_pay) + + def test_update_slip_skips_submitted_slip(self): + payroll_entry = make_test_payroll_entry(self.department) + payroll_entry.submit_salary_slips() + slip = get_slips(payroll_entry.name, docstatus=1)[0] + self.assertEqual(payroll.update_slip(slip, show_message=False), "skipped") + + def test_update_slips_enqueues_and_returns_draft_count(self): + payroll_entry = make_test_payroll_entry(self.department) + with patch.object(payroll, "enqueue") as enqueue: + self.assertEqual(payroll.update_slips(payroll_entry.name), 1) + enqueue.assert_called_once() + self.assertEqual(enqueue.call_args.kwargs["payroll_entry"], payroll_entry.name) + + payroll.enqueue_update_slips(payroll_entry.name) + self.assertEqual(len(get_slips(payroll_entry.name, docstatus=0)), 1) + + def test_create_journal_entry_requires_submitted_slips(self): + payroll_entry = make_test_payroll_entry(self.department) + self.assertRaises(frappe.ValidationError, payroll.create_journal_entry, payroll_entry.name) + + def test_create_journal_entry_for_submitted_slips(self): + payroll_entry = make_test_payroll_entry(self.department) + slip = frappe.get_doc("Salary Slip", get_slips(payroll_entry.name)[0]) + slip.submit() + + self.assertEqual(payroll.create_journal_entry(payroll_entry.name), "True") + journal_entry = frappe.db.get_value("Salary Slip", slip.name, "journal_entry") + self.assertTrue(journal_entry) + self.assertEqual(frappe.db.get_value("Journal Entry", journal_entry, "docstatus"), 1) + + def test_create_journal_entry_returns_none_when_already_processed(self): + payroll_entry = make_test_payroll_entry(self.department) + payroll_entry.submit_salary_slips() + payroll_entry.reload() + self.assertEqual(payroll_entry.salary_slips_submitted, 1) + self.assertIsNone(payroll.create_journal_entry(payroll_entry.name)) + + def test_get_amounts_summary(self): + payroll_entry = make_test_payroll_entry(self.department) + frappe.db.set_value("Salary Component", "Basic Salary", "include_in_payroll_summary", 1) + slip = frappe.get_doc("Salary Slip", get_slips(payroll_entry.name)[0]) + basic = next(row.amount for row in slip.earnings if row.salary_component == "Basic Salary") + + summary = payroll.get_amounts_summary(payroll_entry.name) + + self.assertEqual(summary["gross_pay"], slip.gross_pay) + self.assertEqual(summary["net_pay"], slip.net_pay) + self.assertEqual( + summary["components"], [{"component": "Basic Salary", "label": "Basic Salary", "amount": basic}] + ) + + def test_get_amounts_summary_denies_unprivileged_user(self): + payroll_entry = make_test_payroll_entry(self.department) + frappe.set_user("test@example.com") + try: + self.assertRaises(frappe.PermissionError, payroll.get_amounts_summary, payroll_entry.name) + finally: + frappe.set_user("Administrator") + + def test_print_slips_attaches_pdf(self): + payroll_entry = make_test_payroll_entry(self.department) + with patch.object(payroll, "enqueue") as enqueue: + payroll.print_slips(payroll_entry.name) + self.assertEqual(enqueue.call_args.kwargs["kwargs"], payroll_entry.name) + + with patch.object(frappe, "get_print", return_value=blank_pdf()): + attachment = payroll.enqueue_print_slips(payroll_entry.name) + + self.assertEqual(attachment.attached_to_name, payroll_entry.name) + self.assertEqual(attachment.file_name, payroll_entry.name + ".pdf") + + def test_cancel_with_approval_removes_slips_and_journal_entry(self): + set_csf_tz_settings(enable_payroll_approval=1) + payroll_entry = make_test_payroll_entry(self.department) + payroll_entry.submit_salary_slips() + slip = get_slips(payroll_entry.name)[0] + journal_entry = frappe.db.get_value("Salary Slip", slip, "journal_entry") + self.assertTrue(journal_entry) + + payroll_entry.reload() + with patch.object(frappe, "log_error") as log_error: + payroll_entry.cancel() + + log_error.assert_not_called() + self.assertEqual(get_slips(payroll_entry.name), []) + self.assertEqual(frappe.db.get_value("Journal Entry", journal_entry, "docstatus"), 2) + + def test_cancel_without_approval_keeps_default_behaviour(self): + payroll_entry = make_test_payroll_entry(self.department) + payroll_entry.submit_salary_slips() + payroll_entry.reload() + payroll_entry.cancel() + self.assertEqual(payroll_entry.docstatus, 2) + self.assertEqual(get_slips(payroll_entry.name), []) + + +class TestPayrollEntryApprovalWorkflow(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + setup_payroll_master_data() + cls.department = make_department("_Test CSF Payroll Approval") + cls.employee = make_payroll_employee("csf_payroll_approval@example.com", department=cls.department) + assign_salary_structure(cls.employee, "_Test CSF Payroll Approval Structure") + set_csf_tz_settings(enable_payroll_approval=1) + + def make_entry(self, workflow_state): + payroll_entry = make_test_payroll_entry(self.department) + payroll_entry.workflow_state = workflow_state + return payroll_entry + + def test_get_workflow_action(self): + self.assertEqual( + payroll.get_workflow_action(frappe._dict(workflow_state="Approval Requested")), "Submit" + ) + self.assertEqual( + payroll.get_workflow_action(frappe._dict(workflow_state="Change Requested")), "Reject" + ) + self.assertEqual(payroll.get_workflow_action(frappe._dict(workflow_state="Reviewed by HR")), "Submit") + self.assertIsNone(payroll.get_workflow_action(frappe._dict(workflow_state="Salary Slips Created"))) + + def test_approved_entry_submits_salary_slips(self): + payroll_entry = self.make_entry("Approved") + payroll.before_update_after_submit(payroll_entry, None) + self.assertEqual(len(get_slips(payroll_entry.name, docstatus=1)), 1) + + def test_approval_requested_enqueues_slip_workflow(self): + payroll_entry = self.make_entry("Approval Requested") + with patch.object(payroll, "enqueue") as enqueue: + payroll.before_update_after_submit(payroll_entry, None) + params = enqueue.call_args.kwargs["kwargs"] + self.assertEqual(params["action"], "Submit") + self.assertEqual(params["salary_slips"], get_slips(payroll_entry.name)) + + def test_entry_without_approval_does_nothing(self): + payroll_entry = self.make_entry("Approval Requested") + payroll_entry.has_payroll_approval = 0 + with patch.object(payroll, "enqueue") as enqueue: + payroll.before_update_after_submit(payroll_entry, None) + enqueue.assert_not_called() From 85a067ae97c13681e9db73413cffa61f6246b157 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:29 +0300 Subject: [PATCH 32/74] test: cover the salary slip override --- csf_tz/tests/test_hr_payroll_salary_slip.py | 109 ++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 csf_tz/tests/test_hr_payroll_salary_slip.py diff --git a/csf_tz/tests/test_hr_payroll_salary_slip.py b/csf_tz/tests/test_hr_payroll_salary_slip.py new file mode 100644 index 00000000..54274e8b --- /dev/null +++ b/csf_tz/tests/test_hr_payroll_salary_slip.py @@ -0,0 +1,109 @@ +from unittest.mock import patch + +import frappe +from frappe.utils import date_diff, nowdate + +from csf_tz.overrides.salary_slip import generate_password_for_pdf +from csf_tz.tests.hr_payroll_fixtures import ( + HRPayrollTestCase, + assign_salary_structure, + make_payroll_employee, + make_salary_slip, + set_csf_tz_settings, + setup_payroll_master_data, +) + +STRUCTURE = "_Test CSF Salary Slip Structure" + + +class TestSalarySlipOverride(HRPayrollTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + setup_payroll_master_data() + cls.employee = make_payroll_employee("csf_salary_slip@example.com", cell_number="0700000000") + assign_salary_structure(cls.employee, STRUCTURE) + + def setUp(self): + super().setUp() + set_csf_tz_settings( + enable_fixed_working_days_per_month=0, + working_days_per_month=26, + override_salary_slip_email_message=0, + salary_slip_email_message="", + ) + + def test_fixed_working_days_cap_total_and_payment_days(self): + set_csf_tz_settings(enable_fixed_working_days_per_month=1, working_days_per_month=10) + slip = make_salary_slip(self.employee, STRUCTURE) + self.assertEqual(slip.total_working_days, 10) + self.assertEqual(slip.payment_days, 10) + + def test_fixed_working_days_do_not_raise_short_months(self): + set_csf_tz_settings(enable_fixed_working_days_per_month=1, working_days_per_month=40) + slip = make_salary_slip(self.employee, STRUCTURE) + self.assertLess(slip.total_working_days, 40) + self.assertEqual(slip.total_working_days, slip.payment_days) + + def test_working_days_untouched_when_disabled(self): + slip = make_salary_slip(self.employee, STRUCTURE) + month_days = date_diff(slip.end_date, slip.start_date) + 1 + self.assertGreater(slip.total_working_days, 10) + self.assertLessEqual(slip.total_working_days, month_days) + + def test_email_uses_custom_message(self): + set_csf_tz_settings( + override_salary_slip_email_message=1, salary_slip_email_message="Dear staff, slip attached" + ) + slip = make_salary_slip(self.employee, STRUCTURE) + with ( + patch.object(frappe, "sendmail") as sendmail, + patch.object(frappe, "attach_print", return_value={"fname": "x.pdf"}) as attach_print, + ): + slip.email_salary_slip() + + email_args = sendmail.call_args.kwargs + self.assertEqual(email_args["message"], "Dear staff, slip attached") + self.assertEqual(email_args["recipients"], ["csf_salary_slip@example.com"]) + self.assertEqual(email_args["reference_name"], slip.name) + self.assertIsNone(attach_print.call_args.kwargs["password"]) + + def test_email_custom_message_with_password(self): + set_csf_tz_settings(override_salary_slip_email_message=1, salary_slip_email_message="Hello") + frappe.db.set_single_value("Payroll Settings", "encrypt_salary_slips_in_emails", 1) + frappe.db.set_single_value("Payroll Settings", "password_policy", "{first_name}-{cell_number}") + slip = make_salary_slip(self.employee, STRUCTURE) + with ( + patch.object(frappe, "sendmail") as sendmail, + patch.object(frappe, "attach_print", return_value={}) as attach_print, + ): + slip.email_salary_slip() + + self.assertEqual(attach_print.call_args.kwargs["password"], "csf_salary_slip@example.com-0700000000") + self.assertIn("password protected", sendmail.call_args.kwargs["message"]) + + def test_email_custom_message_without_receiver(self): + set_csf_tz_settings(override_salary_slip_email_message=1, salary_slip_email_message="Hello") + frappe.db.set_value("Employee", self.employee, "prefered_email", "") + slip = make_salary_slip(self.employee, STRUCTURE) + with patch.object(frappe, "sendmail") as sendmail, patch.object(frappe, "msgprint") as msgprint: + slip.email_salary_slip() + sendmail.assert_not_called() + self.assertIn("email not found", msgprint.call_args.args[0]) + + def test_email_falls_back_to_hrms_message(self): + slip = make_salary_slip(self.employee, STRUCTURE) + with ( + patch.object(frappe, "sendmail") as sendmail, + patch.object(frappe, "attach_print", return_value={}), + ): + slip.email_salary_slip() + self.assertEqual(sendmail.call_args.kwargs["message"], "Please see attachment") + + def test_generate_password_for_pdf(self): + self.assertEqual(generate_password_for_pdf("{cell_number}", self.employee), "0700000000") + self.assertEqual(generate_password_for_pdf("{name}", self.employee), self.employee) + + def test_slip_posting_date_is_today(self): + slip = make_salary_slip(self.employee, STRUCTURE) + self.assertEqual(str(slip.posting_date), nowdate()) From 26a7c5dd7512f7a9c5e089f7fa3048c85a11d6ee Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:34 +0300 Subject: [PATCH 33/74] test: cover exchange difference calculation on imports --- csf_tz/tests/test_import_exchange.py | 271 +++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 csf_tz/tests/test_import_exchange.py diff --git a/csf_tz/tests/test_import_exchange.py b/csf_tz/tests/test_import_exchange.py new file mode 100644 index 00000000..ae231067 --- /dev/null +++ b/csf_tz/tests/test_import_exchange.py @@ -0,0 +1,271 @@ +from unittest.mock import patch + +import frappe +from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import make_landed_cost_voucher +from frappe.tests import IntegrationTestCase + +from csf_tz.csftz_hooks.exchange_calculations import ( + calculate_lcv_exchange_difference, + create_exchange_difference_je, + create_manual_exchange_entry, + debug_payment_linking_issue, + get_exchange_gain_loss_account, + get_import_settings, + get_supplier_payable_account, + is_payable_account, + manually_link_payment_to_tracker, +) +from csf_tz.tests.import_fixtures import ( + COMPANY, + INR_BANK, + INR_SUPPLIER, + ORIGINAL_RATE, + USD_SUPPLIER, + get_tracker, + make_foreign_purchase_invoice, + make_plain_supplier_payment, + make_supplier_payment, + set_import_settings, +) + +GAIN_LOSS_ACCOUNT = "Exchange Gain/Loss - _TC" +CREDITORS = "Creditors - _TC" + + +class TestImportExchangeDifferences(IntegrationTestCase): + """Journal entries, manual entries and LCV differences on a company-currency payable supplier.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + set_import_settings(auto_create_journal_entries=1) + + def make_import_with_payment(self, amount=500, rate=2600): + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER) + payment = make_supplier_payment(invoice.name, amount, rate) + return invoice, payment + + def get_row(self, journal_entry, account): + return next(row for row in journal_entry.accounts if row.account == account) + + def test_manual_link_creates_gain_journal_entry(self): + invoice, payment = self.make_import_with_payment() + tracker = get_tracker(invoice.name) + self.assertEqual(tracker.payments, []) + result = manually_link_payment_to_tracker(payment.name, tracker.name) + self.assertIn("success", result) + tracker.reload() + row = tracker.payments[0] + self.assertEqual(row.payment_amount_foreign, 500) + self.assertEqual(row.payment_exchange_rate, 2600) + self.assertEqual(row.exchange_difference, 50000) + self.assertEqual(row.journal_entry_created, 1) + difference = tracker.exchange_differences[0] + self.assertEqual(difference.difference_type, "Gain") + journal_entry = frappe.get_doc("Journal Entry", difference.journal_entry) + self.assertEqual(journal_entry.docstatus, 1) + self.assertEqual(journal_entry.voucher_type, "Exchange Gain Or Loss") + payable = self.get_row(journal_entry, CREDITORS) + self.assertEqual( + (payable.party_type, payable.party, payable.debit), ("Supplier", INR_SUPPLIER, 50000) + ) + self.assertEqual(self.get_row(journal_entry, GAIN_LOSS_ACCOUNT).credit, 50000) + self.assertEqual( + frappe.db.get_value("Payment Entry", payment.name, "foreign_import_tracker"), tracker.name + ) + + def test_loss_journal_entry_uses_configured_loss_account(self): + loss_account = "_Test Exchange Gain/Loss - _TC" + frappe.db.set_single_value("Foreign Import Settings", "default_exchange_loss_account", loss_account) + self.addCleanup( + frappe.db.set_single_value, "Foreign Import Settings", "default_exchange_loss_account", None + ) + invoice, payment = self.make_import_with_payment(rate=2400) + tracker = get_tracker(invoice.name) + manually_link_payment_to_tracker(payment.name, tracker.name) + tracker.reload() + difference = tracker.exchange_differences[0] + self.assertEqual(difference.difference_type, "Loss") + self.assertEqual(difference.amount, 50000) + journal_entry = frappe.get_doc("Journal Entry", difference.journal_entry) + self.assertEqual(self.get_row(journal_entry, loss_account).debit, 50000) + self.assertEqual(self.get_row(journal_entry, CREDITORS).credit, 50000) + self.assertEqual(tracker.total_gain_loss, -50000) + + def test_manual_link_validations(self): + invoice, payment = self.make_import_with_payment() + tracker = get_tracker(invoice.name) + other_tracker = get_tracker(make_foreign_purchase_invoice().name) + self.assertIn( + "doesn't match", manually_link_payment_to_tracker(payment.name, other_tracker.name)["error"] + ) + manually_link_payment_to_tracker(payment.name, tracker.name) + self.assertIn("already linked", manually_link_payment_to_tracker(payment.name, tracker.name)["error"]) + unrelated = make_plain_supplier_payment("_Test Supplier 2", 100) + unrelated.insert() + unrelated.submit() + self.assertIn("No active trackers", manually_link_payment_to_tracker(unrelated.name)["error"]) + + def test_manual_link_picks_open_supplier_tracker(self): + invoice, payment = self.make_import_with_payment() + result = manually_link_payment_to_tracker(payment.name) + self.assertIn("success", result) + linked = frappe.db.get_value("Payment Entry", payment.name, "foreign_import_tracker") + self.assertEqual(frappe.db.get_value("Foreign Import Transaction", linked, "supplier"), INR_SUPPLIER) + + def test_payment_debug_report(self): + invoice, payment = self.make_import_with_payment() + info = debug_payment_linking_issue(payment.name) + self.assertEqual(info["issues"], []) + self.assertEqual(info["payment_details"]["party"], INR_SUPPLIER) + tracker_info = next(t for t in info["potential_trackers"] if t["purchase_invoice"] == invoice.name) + self.assertFalse(tracker_info["currency_match"]) + self.assertTrue(tracker_info["status_ok"]) + self.assertIn("Currency mismatch", tracker_info["issues"][0]) + manually_link_payment_to_tracker(payment.name, tracker_info["name"]) + tracker_info = next( + t + for t in debug_payment_linking_issue(payment.name)["potential_trackers"] + if t["purchase_invoice"] == invoice.name + ) + self.assertIn("Payment already linked to this tracker", tracker_info["issues"]) + self.assertIn("error", debug_payment_linking_issue("PE-DOES-NOT-EXIST")) + + def test_debug_report_flags_receive_payments(self): + payment = make_plain_supplier_payment(INR_SUPPLIER, 100) + payment.insert() + info = debug_payment_linking_issue(payment.name) + self.assertIn("Payment Entry not submitted (docstatus = 0)", info["issues"]) + + def test_cancelling_payment_cancels_exchange_journal_entry(self): + invoice, payment = self.make_import_with_payment() + tracker = get_tracker(invoice.name) + manually_link_payment_to_tracker(payment.name, tracker.name) + journal_entry = get_tracker(invoice.name).exchange_differences[0].journal_entry + frappe.get_doc("Payment Entry", payment.name).cancel() + tracker.reload() + self.assertEqual(tracker.payments, []) + self.assertEqual(tracker.exchange_differences, []) + self.assertEqual(frappe.db.get_value("Journal Entry", journal_entry, "docstatus"), 2) + + def test_manual_exchange_entry(self): + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER) + tracker = get_tracker(invoice.name) + name = create_manual_exchange_entry( + tracker.name, "Purchase Invoice", invoice.name, "Loss", 1200, "customs" + ) + self.assertEqual(name, tracker.name) + tracker.reload() + difference = tracker.exchange_differences[0] + self.assertEqual( + (difference.difference_type, difference.amount, difference.remarks), ("Loss", 1200, "customs") + ) + journal_entry = frappe.get_doc("Journal Entry", difference.journal_entry) + self.assertEqual(self.get_row(journal_entry, GAIN_LOSS_ACCOUNT).debit, 1200) + self.assertEqual(self.get_row(journal_entry, CREDITORS).credit, 1200) + self.assertIn("Manual Loss Entry", journal_entry.user_remark) + with self.assertRaisesRegex(frappe.ValidationError, "greater than 0"): + create_manual_exchange_entry(tracker.name, "Purchase Invoice", invoice.name, "Gain", 0, "") + + def test_manual_exchange_entry_requires_submitted_tracker(self): + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER) + tracker = get_tracker(invoice.name) + tracker.db_set("docstatus", 0) + with self.assertRaisesRegex(frappe.ValidationError, "must be submitted"): + create_manual_exchange_entry(tracker.name, "Purchase Invoice", invoice.name, "Gain", 10, "") + + def test_cancelling_tracker_cancels_journal_entries(self): + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER) + tracker = get_tracker(invoice.name) + create_manual_exchange_entry(tracker.name, "Purchase Invoice", invoice.name, "Gain", 300, "manual") + tracker.reload() + journal_entry = tracker.exchange_differences[0].journal_entry + tracker.cancel() + self.assertEqual(frappe.db.get_value("Journal Entry", journal_entry, "docstatus"), 2) + self.assertEqual( + frappe.db.get_value("Foreign Import Transaction", tracker.name, "status"), "Cancelled" + ) + + def test_journal_entry_requires_gain_loss_account(self): + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER) + tracker = get_tracker(invoice.name) + with ( + patch( + "csf_tz.csftz_hooks.exchange_calculations.get_exchange_gain_loss_account", return_value=None + ), + self.assertRaisesRegex(frappe.ValidationError, "not configured"), + ): + create_exchange_difference_je(tracker, 100, "Gain", invoice, "test") + + def test_account_lookups(self): + self.assertEqual(get_supplier_payable_account(USD_SUPPLIER, COMPANY), "_Test Payable USD - _TC") + supplier = frappe.get_doc( + { + "doctype": "Supplier", + "supplier_name": "_Test Import Supplier", + "supplier_group": "_Test Supplier Group", + } + ).insert() + self.assertEqual(get_supplier_payable_account(supplier.name, COMPANY), CREDITORS) + self.assertTrue(is_payable_account(CREDITORS)) + self.assertFalse(is_payable_account(INR_BANK)) + self.assertEqual(get_exchange_gain_loss_account(COMPANY), GAIN_LOSS_ACCOUNT) + + def test_import_settings_get_default_company(self): + frappe.db.set_single_value("Foreign Import Settings", "company", None) + settings = get_import_settings(COMPANY) + self.assertEqual(settings.company, COMPANY) + self.assertEqual(frappe.db.get_single_value("Foreign Import Settings", "company"), COMPANY) + + def test_landed_cost_voucher_links_and_unlinks(self): + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER, update_stock=1) + voucher = make_landed_cost_voucher( + receipt_document_type="Purchase Invoice", receipt_document=invoice.name, charges=50 + ) + item = voucher.items[0] + self.assertEqual(item.custom_total_amount, item.amount + item.applicable_charges) + self.assertEqual(voucher.custom_grand_total, sum(row.custom_total_amount for row in voucher.items)) + tracker = get_tracker(invoice.name) + row = tracker.landed_cost_vouchers[0] + self.assertEqual(row.landed_cost_voucher, voucher.name) + self.assertEqual(row.lcv_amount_base, 50) + self.assertEqual(row.allocated_to_items, 50) + self.assertEqual(row.exchange_rate_used, 1) + self.assertEqual(tracker.exchange_differences, []) + voucher.cancel() + tracker.reload() + self.assertEqual(tracker.landed_cost_vouchers, []) + + def test_landed_cost_exchange_difference_with_rate(self): + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER, update_stock=1) + voucher = make_landed_cost_voucher( + receipt_document_type="Purchase Invoice", receipt_document=invoice.name, charges=50 + ) + tracker = get_tracker(invoice.name) + voucher_at_new_rate = frappe._dict( + name=voucher.name, + posting_date=voucher.posting_date, + total_taxes_and_charges=50, + conversion_rate=2600, + ) + calculate_lcv_exchange_difference(tracker, voucher_at_new_rate) + tracker.reload() + difference = tracker.exchange_differences[0] + self.assertEqual(difference.reference_type, "Landed Cost Voucher") + self.assertEqual(difference.difference_type, "Loss") + self.assertAlmostEqual(difference.amount, 50 - 50 / 2600 * ORIGINAL_RATE, places=2) + self.assertTrue(difference.journal_entry) + self.assertEqual(tracker.get_exchange_summary()["lcv_differences"], difference.amount) + frappe.get_doc("Landed Cost Voucher", voucher.name).cancel() + tracker.reload() + self.assertEqual(tracker.exchange_differences, []) + + def test_landed_cost_tracking_can_be_disabled(self): + frappe.db.set_single_value("Foreign Import Settings", "enable_lcv_exchange_tracking", 0) + self.addCleanup( + frappe.db.set_single_value, "Foreign Import Settings", "enable_lcv_exchange_tracking", 1 + ) + invoice = make_foreign_purchase_invoice(supplier=INR_SUPPLIER) + tracker = get_tracker(invoice.name) + calculate_lcv_exchange_difference(tracker, frappe._dict(name="LCV", conversion_rate=2600)) + self.assertEqual(tracker.exchange_differences, []) From 3587ef38e22d42e2ff7d251f3c7d1da51d50ca20 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:38 +0300 Subject: [PATCH 34/74] test: cover import tracker creation and cancellation --- csf_tz/tests/test_import_tracker.py | 145 ++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 csf_tz/tests/test_import_tracker.py diff --git a/csf_tz/tests/test_import_tracker.py b/csf_tz/tests/test_import_tracker.py new file mode 100644 index 00000000..c8e4f6bb --- /dev/null +++ b/csf_tz/tests/test_import_tracker.py @@ -0,0 +1,145 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import nowdate + +from csf_tz.csf_tz.doctype.foreign_import_transaction.foreign_import_transaction import ( + ForeignImportTransaction, +) +from csf_tz.csftz_hooks.exchange_calculations import update_pending_transactions +from csf_tz.tests.import_fixtures import ( + COMPANY, + INR_BANK, + INR_SUPPLIER, + ORIGINAL_RATE, + get_tracker, + make_foreign_purchase_invoice, + make_purchase_invoice, + make_supplier_payment, + set_import_settings, +) + + +class TestForeignImportTracker(IntegrationTestCase): + """Tracker behaviour beyond the DocType test: payment sides, unlinking, scheduler.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + set_import_settings() + + def test_invoice_keeps_tracker_reference_in_memory(self): + invoice = make_foreign_purchase_invoice() + tracker = get_tracker(invoice.name) + self.assertEqual(invoice.foreign_import_tracker, tracker.name) + self.assertEqual( + frappe.db.get_value("Purchase Invoice", invoice.name, "foreign_import_tracker"), tracker.name + ) + + def test_payment_from_company_currency_bank_uses_supplier_side(self): + invoice = make_foreign_purchase_invoice() + payment = make_supplier_payment(invoice.name, 400, 2400, bank_account=INR_BANK) + tracker = get_tracker(invoice.name) + row = tracker.payments[0] + self.assertEqual(row.payment_entry, payment.name) + self.assertEqual(row.payment_amount_foreign, 400) + self.assertEqual(row.payment_exchange_rate, 2400) + self.assertEqual(row.exchange_difference, -40000) + self.assertEqual(tracker.exchange_differences[0].difference_type, "Loss") + self.assertEqual(tracker.exchange_differences[0].amount, 40000) + self.assertEqual(tracker.total_gain_loss, -40000) + self.assertEqual(payment.foreign_import_tracker, tracker.name) + + def test_payment_at_original_rate_records_no_difference(self): + invoice = make_foreign_purchase_invoice() + make_supplier_payment(invoice.name, 300, ORIGINAL_RATE) + tracker = get_tracker(invoice.name) + self.assertEqual(len(tracker.payments), 1) + self.assertEqual(tracker.exchange_differences, []) + self.assertEqual(tracker.status, "Active") + + def test_cancelling_payment_unlinks_it(self): + invoice = make_foreign_purchase_invoice() + payment = make_supplier_payment(invoice.name, 500, 2600) + self.assertEqual(len(get_tracker(invoice.name).payments), 1) + payment.cancel() + tracker = get_tracker(invoice.name) + self.assertEqual(tracker.payments, []) + self.assertEqual(tracker.exchange_differences, []) + self.assertEqual(tracker.total_gain_loss, 0) + + def test_tracker_validations(self): + invoice = make_purchase_invoice(supplier=INR_SUPPLIER, currency="INR", rate=100) + tracker = frappe.get_doc( + { + "doctype": "Foreign Import Transaction", + "purchase_invoice": invoice.name, + "supplier": INR_SUPPLIER, + "currency": "INR", + "company": COMPANY, + "transaction_date": nowdate(), + } + ) + with self.assertRaisesRegex(frappe.ValidationError, "foreign currency"): + tracker.insert() + tracker.currency = None + with self.assertRaisesRegex(frappe.ValidationError, "Currency is required"): + tracker.validate_currency() + + def test_draft_tracker_totals_and_summary(self): + invoice = make_purchase_invoice( + supplier=INR_SUPPLIER, currency="EUR", conversion_rate=80, rate=100, do_not_submit=True + ) + tracker = frappe.get_doc( + { + "doctype": "Foreign Import Transaction", + "purchase_invoice": invoice.name, + "supplier": INR_SUPPLIER, + "currency": "EUR", + "company": COMPANY, + "transaction_date": nowdate(), + "invoice_amount_foreign": 1000, + } + ) + for difference_type, amount in (("Gain", 300), ("Loss", 100)): + tracker.append( + "exchange_differences", + { + "reference_type": "Purchase Invoice", + "reference_name": invoice.name, + "difference_type": difference_type, + "amount": amount, + "posting_date": nowdate(), + }, + ) + tracker.insert() + self.assertEqual(tracker.status, "Draft") + self.assertEqual(tracker.total_gain_loss, 200) + self.assertEqual(tracker.net_difference, 200) + summary = tracker.get_exchange_summary() + self.assertEqual(summary["total_gain"], 300) + self.assertEqual(summary["total_loss"], 100) + self.assertEqual(summary["manual_entries"], 400) + self.assertEqual(summary["net_difference"], 200) + + def test_update_pending_transactions_refreshes_status(self): + invoice = make_foreign_purchase_invoice() + make_supplier_payment(invoice.name, 1000, ORIGINAL_RATE) + tracker = get_tracker(invoice.name) + self.assertEqual(tracker.status, "Completed") + tracker.db_set("status", "Active") + update_pending_transactions() + self.assertEqual( + frappe.db.get_value("Foreign Import Transaction", tracker.name, "status"), "Completed" + ) + + def test_update_pending_transactions_logs_errors(self): + make_foreign_purchase_invoice() + with ( + patch.object(ForeignImportTransaction, "calculate_totals", side_effect=Exception("boom")), + patch("frappe.log_error") as log_error, + ): + update_pending_transactions() + self.assertTrue(log_error.called) + self.assertIn("boom", log_error.call_args.args[0]) From e71539e27d913f0766e0e5615523e81661dd4d76 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:54 +0300 Subject: [PATCH 35/74] test: cover the monkey patches and app configuration --- csf_tz/tests/test_integrations_core.py | 131 +++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 csf_tz/tests/test_integrations_core.py diff --git a/csf_tz/tests/test_integrations_core.py b/csf_tz/tests/test_integrations_core.py new file mode 100644 index 00000000..00da0777 --- /dev/null +++ b/csf_tz/tests/test_integrations_core.py @@ -0,0 +1,131 @@ +from importlib import import_module as real_import_module +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import frappe +from frappe.database.database import Database +from frappe.tests import IntegrationTestCase + +import csf_tz +from csf_tz.config import accounts, desktop, docs, purchase_and_stock_management, sales_and_marketing, stock +from csf_tz.config import csf_tz as csf_tz_config +from csf_tz.monkey_patches.db_transaction_writes import check_transaction_status + + +def fake_db(transaction_writes=0, auto_commit_on_many_writes=0): + return SimpleNamespace( + transaction_writes=transaction_writes, + auto_commit_on_many_writes=auto_commit_on_many_writes, + commit=MagicMock(), + ) + + +class TestConfig(IntegrationTestCase): + def test_module_config_get_data(self): + for module in (accounts, csf_tz_config, purchase_and_stock_management, sales_and_marketing, stock): + with self.subTest(module=module.__name__): + data = module.get_data() + self.assertIsInstance(data, list) + for section in data: + self.assertIn("label", section) + self.assertIsInstance(section["items"], list) + for item in section["items"]: + self.assertIn(item["type"], ("doctype", "report", "page")) + self.assertIn("name", item) + + def test_desktop_and_docs(self): + modules = {entry["module_name"] for entry in desktop.get_data()} + self.assertEqual( + modules, + { + "CSF TZ", + "Purchase and Stock Management", + "Sales and Marketing", + "VFD Providers", + "VFD Settings", + }, + ) + context = frappe._dict() + docs.get_context(context) + self.assertEqual(context.brand_html, "CSF TZ") + + +class TestTransactionWritesMonkeyPatch(IntegrationTestCase): + def test_patch_is_installed(self): + self.assertIs(Database.check_transaction_status, check_transaction_status) + + def test_implicit_commit_statements_raise_after_writes(self): + db = fake_db(transaction_writes=1) + for statement in ( + "ALTER TABLE x", + "drop table x", + "create table x", + "truncate x", + "START TRANSACTION", + ): + with self.assertRaisesRegex(Exception, "implicit commit"): + check_transaction_status(db, statement) + check_transaction_status(fake_db(transaction_writes=0), "ALTER TABLE x") + + def test_commit_resets_counter_and_writes_are_counted(self): + db = fake_db(transaction_writes=5) + check_transaction_status(db, "commit") + self.assertEqual(db.transaction_writes, 0) + check_transaction_status(db, "select 1") + self.assertEqual(db.transaction_writes, 0) + check_transaction_status(db, "UPDATE t set a=1") + check_transaction_status(db, "insert into t values (1)") + check_transaction_status(db, "delete from t") + self.assertEqual(db.transaction_writes, 3) + check_transaction_status(db, "rollback") + self.assertEqual(db.transaction_writes, 0) + + def test_too_many_writes(self): + with patch.dict(frappe.conf, {"_max_writes_allowed": 2}): + db = fake_db(transaction_writes=2) + with self.assertRaisesRegex(frappe.ValidationError, "Too many writes"): + check_transaction_status(db, "update t set a=1") + + db = fake_db(transaction_writes=2, auto_commit_on_many_writes=1) + check_transaction_status(db, "update t set a=1") + db.commit.assert_called_once() + + +class TestAppInit(IntegrationTestCase): + def test_hooks_and_connect_are_wrapped(self): + self.assertIs(frappe.get_hooks, csf_tz.get_hooks) + self.assertIs(frappe.connect, csf_tz.connect) + self.assertTrue(csf_tz.patches_loaded) + self.assertIn("csf_tz", frappe.get_hooks("app_name")) + + def test_load_monkey_patches_guards(self): + with patch.object(csf_tz, "patches_loaded", False): + with patch.object(frappe.local, "site", None): + csf_tz.load_monkey_patches() + self.assertFalse(csf_tz.patches_loaded) + + with patch("frappe.get_installed_apps", return_value=["frappe"]): + csf_tz.load_monkey_patches() + self.assertFalse(csf_tz.patches_loaded) + + imported = [] + + def fake_import(name, *args, **kwargs): + if name.startswith("csf_tz.monkey_patches."): + imported.append(name) + return None + return real_import_module(name, *args, **kwargs) + + with patch("importlib.import_module", side_effect=fake_import): + csf_tz.load_monkey_patches() + self.assertTrue(csf_tz.patches_loaded) + self.assertEqual(imported, ["csf_tz.monkey_patches.db_transaction_writes"]) + + with patch("importlib.import_module", side_effect=fake_import): + csf_tz.load_monkey_patches() + self.assertEqual(len(imported), 1) + + def test_console_publishes_to_current_user(self): + with patch("frappe.publish_realtime") as publish: + csf_tz.console("a", 1) + publish.assert_called_once_with("out_to_console", ("a", 1), user=frappe.session.user) From 793d9adc4e226be77f220cb16c8d1de43b660351 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:57:58 +0300 Subject: [PATCH 36/74] test: cover the KCB payment file build with mocked http --- csf_tz/tests/test_integrations_kcb.py | 491 ++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 csf_tz/tests/test_integrations_kcb.py diff --git a/csf_tz/tests/test_integrations_kcb.py b/csf_tz/tests/test_integrations_kcb.py new file mode 100644 index 00000000..3a576361 --- /dev/null +++ b/csf_tz/tests/test_integrations_kcb.py @@ -0,0 +1,491 @@ +import base64 +import hashlib +import json +from datetime import datetime, timedelta +from io import BytesIO +from unittest.mock import patch + +import frappe +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from frappe.tests import IntegrationTestCase +from frappe.utils import nowdate +from pypdf import PdfWriter + +from csf_tz.kcb import payments +from csf_tz.kcb.api import kcb_api +from csf_tz.kcb.doctype.kcb_payments_initiation.kcb_payments_initiation import _clean, _purpose +from csf_tz.kcb.payments import ( + make_kcb_payments_initiation_from_payment_entries, + make_kcb_payments_initiation_from_payroll_entry, +) +from csf_tz.kcb.pgp import encrypt_pgp +from csf_tz.kcb.utils.crypto_utils import generate_checksum, sign_checksum_with_p12 +from csf_tz.tests.integration_fixtures import ( + COMPANY, + FakeResponse, + insert_stub, + make_p12, + make_payroll_entry_stub, + make_pgp_key, + make_salary_slip_stub, + pgp_decrypt, +) + +BANK = "_Test KCB Bank" + + +def blank_pdf(): + writer = PdfWriter() + writer.add_blank_page(width=72, height=72) + buffer = BytesIO() + writer.write(buffer) + return buffer.getvalue() + + +P12_PASSWORD = "secret" +EMPLOYEES = ["_T-Employee-00001", "_T-Employee-00002"] + + +def make_bank_account(account_name, **values): + name = f"{account_name} - {BANK}" + if frappe.db.exists("Bank Account", name): + return frappe.get_doc("Bank Account", name) + if not frappe.db.exists("Bank", BANK): + frappe.get_doc({"doctype": "Bank", "bank_name": BANK}).insert() + return frappe.get_doc( + {"doctype": "Bank Account", "account_name": account_name, "bank": BANK, **values} + ).insert() + + +def make_p12_file(): + p12_bytes, private_key = make_p12(P12_PASSWORD) + file_doc = frappe.get_doc( + {"doctype": "File", "file_name": "csf_tz_kcb_test.p12", "is_private": 1, "content": p12_bytes} + ).insert() + return file_doc.file_url, private_key + + +def configure_kcb_settings(public_key, p12_url, bank_account, **overrides): + settings = frappe.get_single("KCB Settings") + settings.update( + { + "enabled": 1, + "token_url": "https://kcb.test/token", + "file_details_submission_url": "https://kcb.test/details", + "file_upload_url": "https://kcb.test/upload", + "file_status_check_url": "https://kcb.test/status", + "username": "kcbuser", + "password": "kcbpass", + "partner_code": "PARTNER", + "processor_code": "PROC", + "subsidiary_code": "SUB", + "template_name": "TEMPLATE", + "default_bank_account": bank_account, + "pgp_public_key": public_key, + "p12_file": p12_url, + "p12_password": P12_PASSWORD, + **overrides, + } + ) + settings.save(ignore_permissions=True) + return settings + + +def make_payment_entry_stub(party_bank_account, amount=1000, docstatus=1, payment_type="Pay"): + return insert_stub( + { + "doctype": "Payment Entry", + "naming_series": "ACC-PAY-.YYYY.-", + "payment_type": payment_type, + "company": COMPANY, + "posting_date": nowdate(), + "party_type": "Supplier", + "party": "_Test Supplier", + "party_name": "_Test Supplier", + "party_bank_account": party_bank_account, + "paid_from_account_currency": "INR", + "paid_to_account_currency": "INR", + "paid_amount": amount, + "received_amount": amount, + }, + docstatus=docstatus, + ) + + +class TestKCBPayments(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.key = make_pgp_key() + cls.p12_url, cls.private_key = make_p12_file() + cls.company_account = make_bank_account( + "_Test KCB Company", + is_company_account=1, + company=COMPANY, + account="_Test Bank - _TC", + bank_account_no="0011223344", + ) + cls.supplier_account = make_bank_account( + "_Test KCB Supplier", + party_type="Supplier", + party="_Test Supplier", + bank_account_no="5566778899", + kcb_beneficiary_clearing_code="010101", + ) + cls.bad_supplier_account = make_bank_account( + "_Test KCB Supplier Bad", party_type="Supplier", party="_Test Supplier", bank_account_no="1" + ) + cls.settings = configure_kcb_settings(str(cls.key.pubkey), cls.p12_url, cls.company_account.name) + for employee in EMPLOYEES: + frappe.db.set_value( + "Employee", employee, {"bank_ac_no": "9988776655", "kcb_beneficiary_clearing_code": "020202"} + ) + cls.pdf_patch = patch.object(payments, "get_pdf", return_value=blank_pdf()) + cls.pdf_patch.start() + cls.addClassCleanup(cls.pdf_patch.stop) + + def setUp(self): + frappe.cache().delete_value("kcb_token") + frappe.cache().delete_value("kcb_token_expiry") + + def attachments(self, name): + return frappe.get_all( + "File", + filters={"attached_to_doctype": "KCB Payments Initiation", "attached_to_name": name}, + pluck="file_name", + ) + + def make_supplier_batch(self): + entries = [ + make_payment_entry_stub(self.supplier_account.name), + make_payment_entry_stub(self.supplier_account.name, 500), + ] + name = make_kcb_payments_initiation_from_payment_entries([pe.name for pe in entries]) + return frappe.get_doc("KCB Payments Initiation", name), entries + + def test_helpers(self): + self.assertEqual(_clean(None), "") + self.assertEqual(_clean(" a|b\r\nc d "), "a b c d") + self.assertEqual(_purpose("x" * 40), "x" * 25) + self.assertEqual(generate_checksum("abc"), generate_checksum(b"abc")) + self.assertEqual(generate_checksum("abc"), hashlib.sha256(b"abc").hexdigest()) + self.assertEqual(payments._validate_single_value(["INR", "INR", None], "Currency"), "INR") + self.assertEqual(payments._validate_single_value([], "Currency"), "") + self.assertRaisesRegex( + frappe.ValidationError, + "must be the same", + payments._validate_single_value, + ["INR", "USD"], + "Currency", + ) + self.assertEqual(payments._get_bank_account_details(None), {}) + self.assertEqual( + payments._get_bank_account_details(self.company_account.name)["bank_account_no"], "0011223344" + ) + self.assertEqual(kcb_api.is_kcb_enabled(), 1) + + def test_pgp_and_p12_signature(self): + encrypted = encrypt_pgp(b"file bytes", str(self.key.pubkey)) + self.assertEqual(pgp_decrypt(self.key, encrypted), "file bytes") + self.assertEqual(pgp_decrypt(self.key, encrypt_pgp("text", str(self.key.pubkey))), "text") + + checksum = generate_checksum("payload") + signature = sign_checksum_with_p12(checksum) + self.private_key.public_key().verify( + base64.b64decode(signature), checksum.encode(), padding.PKCS1v15(), hashes.SHA256() + ) + + def test_sign_checksum_requires_p12_configuration(self): + frappe.db.set_single_value("KCB Settings", "p12_file", "") + try: + self.assertRaisesRegex( + frappe.ValidationError, "P12 file is missing", sign_checksum_with_p12, "abc" + ) + finally: + frappe.db.set_single_value("KCB Settings", "p12_file", self.p12_url) + + def test_supplier_batch_creates_files_and_signature(self): + doc, entries = self.make_supplier_batch() + self.assertEqual(doc.payment_type, "Supplier") + self.assertEqual(doc.debit_account, "0011223344") + self.assertEqual(doc.currency, "INR") + self.assertEqual(doc.total_amount, 1500) + self.assertEqual(len(doc.kcb_payments_initiation_info), 2) + row = doc.kcb_payments_initiation_info[0] + self.assertEqual((row.source_doctype, row.source_name), ("Payment Entry", entries[0].name)) + self.assertEqual( + (row.transaction_code, row.beneficiary_account, row.beneficiary_clearing_code), + ("59", "5566778899", "010101"), + ) + + self.assertEqual( + sorted(self.attachments(doc.name)), + sorted([f"SUP-{doc.name}-Supplier-Summary.pdf", f"{doc.name}.txt", f"{doc.name}.txt.gpg"]), + ) + self.assertTrue(doc.payment_file.endswith(f"{doc.name}.txt")) + self.assertTrue(doc.encrypted_file.endswith(f"{doc.name}.txt.gpg")) + text_content = frappe.get_doc("File", {"file_url": doc.payment_file}).get_content() + self.assertTrue(text_content.startswith("Debit Account|Beneficiary Name|")) + self.assertTrue(text_content.endswith("\n1500.0")) + self.assertIn( + f"0011223344|_Test Supplier|59|1000.0|INR|5566778899|010101|{entries[0].name}", text_content + ) + self.assertEqual(doc.file_checksum, generate_checksum(text_content)) + self.private_key.public_key().verify( + base64.b64decode(doc.checksum_signature), + doc.file_checksum.encode(), + padding.PKCS1v15(), + hashes.SHA256(), + ) + gpg_content = frappe.get_doc("File", {"file_url": doc.encrypted_file}).get_content() + self.assertEqual(pgp_decrypt(self.key, gpg_content), text_content) + + self.assertRaisesRegex( + frappe.ValidationError, + "already exists", + make_kcb_payments_initiation_from_payment_entries, + json.dumps([entries[0].name]), + ) + + def test_supplier_batch_validations(self): + self.assertRaisesRegex( + frappe.ValidationError, "at least one", make_kcb_payments_initiation_from_payment_entries, [] + ) + draft = make_payment_entry_stub(self.supplier_account.name, docstatus=0) + self.assertRaisesRegex( + frappe.ValidationError, + "Only submitted Pay", + make_kcb_payments_initiation_from_payment_entries, + [draft.name], + ) + receive = make_payment_entry_stub(self.supplier_account.name, payment_type="Receive") + self.assertRaisesRegex( + frappe.ValidationError, + "Only submitted Pay", + make_kcb_payments_initiation_from_payment_entries, + [receive.name], + ) + no_account = make_payment_entry_stub(None) + self.assertRaisesRegex( + frappe.ValidationError, + "Missing supplier bank account", + make_kcb_payments_initiation_from_payment_entries, + [no_account.name], + ) + bad_code = make_payment_entry_stub(self.bad_supplier_account.name) + self.assertRaisesRegex( + frappe.ValidationError, + "Missing beneficiary clearing code", + make_kcb_payments_initiation_from_payment_entries, + [bad_code.name], + ) + frappe.db.set_value( + "Bank Account", self.bad_supplier_account.name, "kcb_beneficiary_clearing_code", "12" + ) + self.assertRaisesRegex( + frappe.ValidationError, + "must be 6 characters", + make_kcb_payments_initiation_from_payment_entries, + [bad_code.name], + ) + frappe.db.set_value( + "Bank Account", self.bad_supplier_account.name, "kcb_beneficiary_clearing_code", "" + ) + + good = make_payment_entry_stub(self.supplier_account.name) + frappe.db.set_single_value("KCB Settings", "default_bank_account", None) + try: + self.assertRaisesRegex( + frappe.ValidationError, + "Company bank account is missing", + make_kcb_payments_initiation_from_payment_entries, + [good.name], + ) + finally: + frappe.db.set_single_value("KCB Settings", "default_bank_account", self.company_account.name) + + frappe.db.set_single_value("KCB Settings", "enabled", 0) + try: + self.assertRaisesRegex( + frappe.ValidationError, + "disabled", + make_kcb_payments_initiation_from_payment_entries, + [good.name], + ) + self.assertEqual(kcb_api.is_kcb_enabled(), 0) + finally: + frappe.db.set_single_value("KCB Settings", "enabled", 1) + + def test_payroll_batch(self): + payroll_entry = make_payroll_entry_stub() + self.assertRaisesRegex( + frappe.ValidationError, + "No Salary Slips found", + make_kcb_payments_initiation_from_payroll_entry, + payroll_entry.name, + ) + slips = [ + make_salary_slip_stub(payroll_entry, EMPLOYEES[0], 700), + make_salary_slip_stub(payroll_entry, EMPLOYEES[1], 300, docstatus=0), + ] + self.assertRaisesRegex( + frappe.ValidationError, + "Submit all Salary Slips first", + make_kcb_payments_initiation_from_payroll_entry, + payroll_entry.name, + ) + frappe.db.set_value("Salary Slip", slips[1].name, "docstatus", 1) + + name = make_kcb_payments_initiation_from_payroll_entry(payroll_entry.name) + doc = frappe.get_doc("KCB Payments Initiation", name) + self.assertEqual(doc.payment_type, "Salary") + self.assertEqual(doc.total_amount, 1000) + self.assertEqual(str(doc.posting_date), str(payroll_entry.posting_date)) + self.assertEqual([row.transaction_code for row in doc.kcb_payments_initiation_info], ["58", "58"]) + self.assertEqual( + {row.my_ref for row in doc.kcb_payments_initiation_info}, {slips[0].name, slips[1].name} + ) + self.assertEqual(doc.kcb_payments_initiation_info[0].beneficiary_clearing_code, "020202") + self.assertIn(f"SUP-{name}-Payroll-Summary.pdf", self.attachments(name)) + + self.assertRaisesRegex( + frappe.ValidationError, + "already exists", + make_kcb_payments_initiation_from_payroll_entry, + payroll_entry.name, + ) + + def test_payroll_batch_employee_validations(self): + payroll_entry = make_payroll_entry_stub() + make_salary_slip_stub(payroll_entry, EMPLOYEES[0], 700) + frappe.db.set_value("Employee", EMPLOYEES[0], "bank_ac_no", "") + try: + self.assertRaisesRegex( + frappe.ValidationError, + "Missing employee bank account", + make_kcb_payments_initiation_from_payroll_entry, + payroll_entry.name, + ) + frappe.db.set_value( + "Employee", EMPLOYEES[0], {"bank_ac_no": "1", "kcb_beneficiary_clearing_code": "12"} + ) + self.assertRaisesRegex( + frappe.ValidationError, + "must be 6 characters", + make_kcb_payments_initiation_from_payroll_entry, + payroll_entry.name, + ) + finally: + frappe.db.set_value( + "Employee", + EMPLOYEES[0], + {"bank_ac_no": "9988776655", "kcb_beneficiary_clearing_code": "020202"}, + ) + + def test_before_save_requires_pgp_key(self): + frappe.db.set_single_value("KCB Settings", "pgp_public_key", "") + try: + entry = make_payment_entry_stub(self.supplier_account.name) + self.assertRaisesRegex( + frappe.ValidationError, + "PGP public key is missing", + make_kcb_payments_initiation_from_payment_entries, + [entry.name], + ) + finally: + frappe.db.set_single_value("KCB Settings", "pgp_public_key", str(self.key.pubkey)) + + def test_get_kcb_token_caches_and_refreshes(self): + with patch( + "requests.post", return_value=FakeResponse(200, {"access_token": "tok1", "expires_in": 3600}) + ) as post: + self.assertEqual(kcb_api.get_kcb_token(), "tok1") + self.assertEqual(kcb_api.get_kcb_token(), "tok1") + self.assertEqual(post.call_count, 1) + self.assertEqual(post.call_args.kwargs["auth"], ("kcbuser", "kcbpass")) + + frappe.cache().set_value( + "kcb_token_expiry", (datetime.now() - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + ) + with patch( + "requests.post", + return_value=FakeResponse(200, {"bearer_token": "tok2", "expires_in_seconds": 120}), + ): + self.assertEqual(kcb_api.get_kcb_token(), "tok2") + + frappe.cache().delete_value("kcb_token") + with patch("requests.post", return_value=FakeResponse(200, {})): + self.assertRaisesRegex(frappe.ValidationError, "Token generation failed", kcb_api.get_kcb_token) + with patch("requests.post", return_value=FakeResponse(500, {}, text="boom")): + self.assertRaisesRegex(frappe.ValidationError, "boom", kcb_api.get_kcb_token) + + frappe.db.set_single_value("KCB Settings", "username", "") + try: + self.assertRaisesRegex(frappe.ValidationError, "username/password", kcb_api.get_kcb_token) + finally: + frappe.db.set_single_value("KCB Settings", "username", "kcbuser") + + def test_submit_uploads_file_details_and_content(self): + doc, _entries = self.make_supplier_batch() + frappe.cache().set_value("kcb_token", "tok") + frappe.cache().set_value( + "kcb_token_expiry", (datetime.now() + timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + ) + + with patch("requests.post", return_value=FakeResponse(200, {"status": "ok"})) as post: + doc.submit() + self.assertEqual(doc.docstatus, 1) + self.assertEqual(post.call_count, 2) + details_call, upload_call = post.call_args_list + payload = details_call.kwargs["json"] + originator = frappe.db.get_value("KCB Payments Initiation", doc.name, "originator_conversation_id") + self.assertTrue(originator) + self.assertEqual(payload["originatorConversationID"], originator) + self.assertEqual(payload["fileName"], f"{doc.name}.txt.gpg") + self.assertEqual(payload["supportingFilesNames"], f"SUP-{doc.name}-Supplier-Summary.pdf") + self.assertEqual(payload["checkSum"], doc.file_checksum) + self.assertEqual(payload["partnerCode"], "PARTNER") + self.assertEqual(details_call.kwargs["headers"]["Authorization"], "Bearer tok") + + files = upload_call.kwargs["files"] + self.assertEqual([entry[0] for entry in files], ["files", "files", "originatorConversationID"]) + self.assertEqual(files[0][1][0], f"{doc.name}.txt.gpg") + self.assertEqual(files[-1][1], (None, originator)) + + with patch("requests.post", return_value=FakeResponse(200, {"fileStatus": "PROCESSED"})) as post: + self.assertEqual(kcb_api.check_file_status(doc.name), {"fileStatus": "PROCESSED"}) + self.assertEqual(post.call_args.kwargs["json"]["fileName"], f"{doc.name}.txt.gpg") + with patch("requests.post", return_value=FakeResponse(400, {}, text="bad")): + self.assertRaisesRegex( + frappe.ValidationError, "status check failed", kcb_api.check_file_status, doc.name + ) + + def test_submission_errors(self): + doc, _entries = self.make_supplier_batch() + frappe.cache().set_value("kcb_token", "tok") + frappe.cache().set_value( + "kcb_token_expiry", (datetime.now() + timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + ) + self.assertRaisesRegex( + frappe.ValidationError, "Originator Conversation ID", kcb_api.check_file_status, doc.name + ) + with patch("requests.post", return_value=FakeResponse(500, {}, text="details down")): + self.assertRaisesRegex(frappe.ValidationError, "details down", kcb_api.submit_file_details, doc) + self.assertTrue( + frappe.db.get_value("KCB Payments Initiation", doc.name, "originator_conversation_id") + ) + with patch("requests.post", return_value=FakeResponse(500, {}, text="upload down")): + self.assertRaisesRegex(frappe.ValidationError, "upload down", kcb_api.upload_encrypted_file, doc) + + pdf = frappe.get_doc("File", {"file_name": f"SUP-{doc.name}-Supplier-Summary.pdf"}) + pdf.delete() + doc.reload() + self.assertRaisesRegex( + frappe.ValidationError, "supporting document", kcb_api._get_supporting_file_docs, doc + ) + + doc.db_set("encrypted_file", "") + self.assertRaisesRegex( + frappe.ValidationError, "Encrypted file is missing", kcb_api.check_file_status, doc.name + ) From 11e9febe3dd7bd12d573dc9577c0334dc0a0751c Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:04 +0300 Subject: [PATCH 37/74] test: cover the biometric log and job card pages --- .../test_integrations_meal_count_pages.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 csf_tz/tests/test_integrations_meal_count_pages.py diff --git a/csf_tz/tests/test_integrations_meal_count_pages.py b/csf_tz/tests/test_integrations_meal_count_pages.py new file mode 100644 index 00000000..ff956289 --- /dev/null +++ b/csf_tz/tests/test_integrations_meal_count_pages.py @@ -0,0 +1,167 @@ +import json + +import frappe +from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record +from frappe.tests import IntegrationTestCase +from frappe.utils import add_to_date, now_datetime + +from csf_tz.csf_tz.page.jobcards.jobcards import get_employees, get_job_cards, save_doc +from csf_tz.csf_tz.page.scan_qrcode.scan_qrcode import add_biometric_log +from csf_tz.sales_and_marketing.doctype.customer_item.customer_item import CustomerItem +from csf_tz.sales_and_marketing.doctype.products_of_interest.products_of_interest import ProductsofInterest +from csf_tz.tests.integration_fixtures import COMPANY + +NO_PERMISSION_USER = "csf_tz_noperm@example.com" + + +def make_user_without_roles(): + if not frappe.db.exists("User", NO_PERMISSION_USER): + frappe.get_doc( + {"doctype": "User", "email": NO_PERMISSION_USER, "first_name": "No Perm", "send_welcome_email": 0} + ).insert(ignore_permissions=True) + frappe.db.delete("Has Role", {"parent": NO_PERMISSION_USER}) + return NO_PERMISSION_USER + + +class TestMealCount(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.get_doc({"doctype": "CSF TZ Biometric User Type", "user_type": "_Test Staff"}).insert() + cls.biometric_user = frappe.get_doc( + { + "doctype": "CSF TZ Biometric User", + "user_id": "BIO-001", + "uid": "1", + "user_name": "Bio One", + "user_type": "_Test Staff", + "erpnext_user": "test@example.com", + } + ).insert() + + def test_biometric_user_is_named_by_user_id(self): + self.assertEqual(self.biometric_user.name, "BIO-001") + with self.assertRaises(frappe.DuplicateEntryError): + frappe.get_doc( + {"doctype": "CSF TZ Biometric User", "user_id": "BIO-001", "uid": "2", "user_name": "Dup"} + ).insert() + + def test_biometric_device_and_meal_type(self): + device = frappe.get_doc( + {"doctype": "CSF TZ Biometric Device", "device_id": "DEV-001", "device_nick_name": "Gate"} + ).insert() + self.assertEqual(device.name, "DEV-001") + + meal = frappe.get_doc( + { + "doctype": "CSF TZ Meal Type", + "meal_name": "_Test Lunch", + "meal_type": "Lunch", + "start_time": "12:00:00", + "end_time": "14:00:00", + } + ).insert() + self.assertEqual(meal.name, "_Test Lunch") + + with self.assertRaises(frappe.ValidationError): + frappe.get_doc( + { + "doctype": "CSF TZ Meal Type", + "meal_name": "_Test Snack", + "meal_type": "Snack", + "start_time": "12:00:00", + "end_time": "14:00:00", + } + ).insert() + + def test_add_biometric_log(self): + log = add_biometric_log("BIO-001") + self.assertEqual(log.doctype, "CSF TZ Biometric Log") + self.assertEqual((log.user_id, log.uid), ("BIO-001", "BIO-001")) + self.assertIsNotNone(log.timestamp) + self.assertTrue(frappe.db.exists("CSF TZ Biometric Log", log.name)) + + frappe.get_doc( + { + "doctype": "CSF TZ Biometric Log", + "biometric_user": "BIO-001", + "user_id": "BIO-001", + "timestamp": now_datetime(), + "punch_direction": "IN", + } + ).insert() + self.assertEqual(frappe.db.count("CSF TZ Biometric Log", {"user_id": "BIO-001"}), 2) + + def test_sales_child_doctypes(self): + self.assertTrue(frappe.get_meta("Products of Interest").istable) + self.assertTrue(frappe.get_meta("Customer Item").istable) + self.assertIsInstance(frappe.new_doc("Products of Interest"), ProductsofInterest) + self.assertIsInstance(frappe.new_doc("Customer Item"), CustomerItem) + + +class TestJobCardsPage(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.work_order = make_wo_order_test_record(item="_Test FG Item", qty=1, company=COMPANY) + cls.job_card = frappe.get_doc( + { + "doctype": "Job Card", + "work_order": cls.work_order.name, + "production_item": cls.work_order.production_item, + "bom_no": cls.work_order.bom_no, + "operation": "_Test Operation 1", + "workstation": "_Test Workstation 1", + "company": COMPANY, + "for_quantity": 1, + "wip_warehouse": cls.work_order.wip_warehouse, + } + ).insert() + + def tearDown(self): + frappe.set_user("Administrator") + + def test_get_job_cards_includes_operation_and_time_logs(self): + cards = {card["name"]: card for card in get_job_cards()} + self.assertIn(self.job_card.name, cards) + card = cards[self.job_card.name] + self.assertEqual(card["operation"].name, "_Test Operation 1") + self.assertEqual(card["time_logs"], []) + self.assertIn("work_order_image", card) + + def test_get_employees(self): + employees = get_employees(COMPANY) + self.assertIn("_T-Employee-00001", [row["name"] for row in employees]) + self.assertEqual(get_employees("No Such Company"), []) + + def test_save_doc_updates_and_submits(self): + start = now_datetime() + payload = { + "name": self.job_card.name, + "remarks": "updated from page", + "time_logs": [ + { + "from_time": str(start), + "to_time": str(add_to_date(start, minutes=30)), + "time_in_mins": 30, + "completed_qty": 1, + } + ], + } + saved = save_doc(json.dumps(payload)) + self.assertEqual(saved.remarks, "updated from page") + self.assertEqual(saved.total_completed_qty, 1) + self.assertEqual(saved.status, "Work In Progress") + + submitted = save_doc(json.dumps({"name": self.job_card.name}), action="Submit") + self.assertEqual(submitted.docstatus, 1) + self.assertNotIn(self.job_card.name, [card["name"] for card in get_job_cards()]) + + def test_page_methods_require_permission(self): + frappe.set_user(make_user_without_roles()) + with self.assertRaises(frappe.PermissionError): + get_job_cards() + with self.assertRaises(frappe.PermissionError): + get_employees(COMPANY) + with self.assertRaises(frappe.PermissionError): + save_doc(json.dumps({"name": self.job_card.name, "remarks": "x"})) From 83e7d6f750104d9f33e5e2e262c91f99b73ed854 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:09 +0300 Subject: [PATCH 38/74] test: cover the Stanbic payment file, PGP and SFTP paths --- csf_tz/tests/test_integrations_stanbic.py | 334 ++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 csf_tz/tests/test_integrations_stanbic.py diff --git a/csf_tz/tests/test_integrations_stanbic.py b/csf_tz/tests/test_integrations_stanbic.py new file mode 100644 index 00000000..b6d7c2b3 --- /dev/null +++ b/csf_tz/tests/test_integrations_stanbic.py @@ -0,0 +1,334 @@ +import json +import os +import re +import tempfile +from unittest.mock import MagicMock, patch + +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz.stanbic import sftp +from csf_tz.stanbic.doctype.stanbic_payments_initiation.xml import get_payment_part, get_xml +from csf_tz.stanbic.payments import make_payments_initiation +from csf_tz.stanbic.pgp import encrypt_pgp +from csf_tz.stanbic.xml import parse_xml +from csf_tz.tests.integration_fixtures import ( + COMPANY, + make_payroll_entry_stub, + make_pgp_key, + make_salary_slip_stub, + pgp_decrypt, +) +from csf_tz.utils.create_custom_fields import create_fields_from_json, load_json + +EMPLOYEES = ["_T-Employee-00001", "_T-Employee-00002"] + + +def strip_creation_time(xml): + return re.sub(r".*?", "", xml) + + +def make_stanbic_setting(public_key): + values = { + "company": COMPANY, + "currency": "INR", + "sftp_user": "wasco", + "sftp_url": "sftp.example.com", + "port": 2222, + "user": "apiuser", + "private_key": "/private/files/stanbic_test_key.pem", + "initiating_party_name": "WASCO", + "customerid": "CUST1", + "ordering_customer_account_number": "0123456789", + "ordering_account_type": "CACC", + "ordering_account_currency": "INR", + "ordering_bank_sort_code": "150000", + "ordering_bank_country_code": "tz", + "charges_bearer": "DEBT", + "pgp_public_key": public_key, + "file_code": "WASCO", + "enabled": 1, + } + name = frappe.db.get_value("Stanbic Setting", {"company": COMPANY, "currency": "INR"}) + doc = frappe.get_doc("Stanbic Setting", name) if name else frappe.new_doc("Stanbic Setting") + doc.update(values) + doc.save() if name else doc.insert() + return doc + + +def ack_xml(message_id, status="ACTC", info="Accepted"): + return f""" + + + + {message_id} + {status} + {info} + + +""" + + +def audit_xml(message_id, statuses): + transactions = "".join( + f"{slip}{reason}" + for slip, reason in statuses + ) + return f""" + + + {message_id} + {transactions} + +""" + + +class TestStanbicPayments(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + create_fields_from_json(load_json("16_payroll_entry_cheque.json")) + cls.key = make_pgp_key() + cls.setting = make_stanbic_setting(str(cls.key.pubkey)) + cls.payroll_entry = make_payroll_entry_stub() + cls.slips = [ + make_salary_slip_stub(cls.payroll_entry, EMPLOYEES[0], 1000.5), + make_salary_slip_stub(cls.payroll_entry, EMPLOYEES[1], 2000), + ] + cls.temp_dir = tempfile.mkdtemp(prefix="csf_tz_stanbic_") + cls.path_patch = patch.object( + sftp, "get_absolute_path", side_effect=lambda path: os.path.join(cls.temp_dir, path.strip("/")) + ) + cls.path_patch.start() + cls.addClassCleanup(cls.path_patch.stop) + cls.commit_patch = patch.object(frappe.db, "commit") + cls.commit_patch.start() + cls.addClassCleanup(cls.commit_patch.stop) + + def make_initiation(self): + frappe.db.set_value("Payroll Entry", self.payroll_entry.name, "cheque_number", None) + return make_payments_initiation(self.payroll_entry.name, "INR") + + def test_encrypt_pgp_round_trip(self): + encrypted = encrypt_pgp("", str(self.key.pubkey)) + self.assertTrue(encrypted.startswith("-----BEGIN PGP MESSAGE-----")) + self.assertEqual(pgp_decrypt(self.key, encrypted), "") + + def test_make_payments_initiation_builds_xml_and_marks_payroll_entry(self): + doc = self.make_initiation() + self.assertTrue(doc.name.startswith(self.payroll_entry.name)) + self.assertEqual(doc.stanbic_setting, self.setting.name) + self.assertEqual(doc.number_of_transactions, 2) + self.assertEqual(doc.control_sum, 3000.5) + self.assertEqual(doc.file_code, "WASCO") + self.assertEqual( + sorted(row.salary_slip for row in doc.stanbic_payments_info), sorted(s.name for s in self.slips) + ) + amounts = {row.salary_slip: row.transfer_amount for row in doc.stanbic_payments_info} + self.assertEqual(amounts[self.slips[0].name], 1000.5) + self.assertIn("2", doc.xml) + self.assertIn("3000.50", doc.xml) + self.assertIn("TZ", doc.xml) + self.assertIn(f"{self.slips[0].name}", doc.xml) + self.assertEqual(pgp_decrypt(self.key, doc.encrypted_xml), doc.xml) + cheque = frappe.db.get_value( + "Payroll Entry", self.payroll_entry.name, ["cheque_number", "cheque_date"] + ) + self.assertEqual(cheque, (doc.name, doc.posting_date)) + + self.assertRaisesRegex( + frappe.ValidationError, + "already created", + make_payments_initiation, + self.payroll_entry.name, + "INR", + ) + + def test_make_payments_initiation_requires_setting(self): + frappe.db.set_value("Payroll Entry", self.payroll_entry.name, "cheque_number", None) + with self.assertRaises(frappe.DoesNotExistError): + make_payments_initiation(self.payroll_entry.name, "USD") + self.assertRaisesRegex( + frappe.ValidationError, + "Stanbic Setting not found", + make_payments_initiation, + self.payroll_entry.name, + None, + ) + + def test_draft_salary_slip_blocks_initiation(self): + draft = make_salary_slip_stub(self.payroll_entry, EMPLOYEES[0], 10, docstatus=0) + try: + self.assertRaisesRegex(frappe.ValidationError, "not submitted", self.make_initiation) + finally: + frappe.delete_doc("Salary Slip", draft.name, force=True) + + def test_payment_part_defaults_country_codes(self): + payment = frappe._dict( + salary_slip="SS-1", + beneficiary_account_currency="TZS", + transfer_amount=10, + beneficiary_bank_sort_code="1", + beneficiary_bank_name="Bank", + beneficiary_bank_country_code=None, + beneficiary_name="Someone", + beneficiary_country="ke", + beneficiary_account_number="123", + beneficiary_account_type=0, + ) + part = get_payment_part(payment) + self.assertIn('10.00', part) + self.assertIn("TZ", part) + self.assertIn("KE", part) + doc = self.make_initiation() + self.assertEqual(strip_creation_time(get_xml(doc)), strip_creation_time(doc.xml)) + + def test_submit_writes_outbox_file_and_processes_status_files(self): + doc = self.make_initiation() + doc.submit() + outbox = os.path.join(self.temp_dir, "private/files/stanbic/outbox") + files = os.listdir(outbox) + self.assertEqual(len(files), 1) + self.assertTrue(files[0].startswith("WASCO_H2H_Pain001v3_TZ_WASCO_")) + with open(os.path.join(outbox, files[0])) as handle: + self.assertEqual(handle.read(), doc.encrypted_xml) + + inbox = sftp.get_local_path(["private", "files", "stanbic", "inbox"]) + os.makedirs(inbox, exist_ok=True) + with open(os.path.join(inbox, "ACK_1.xml"), "w") as handle: + handle.write(ack_xml(doc.name)) + with open(os.path.join(inbox, "INTAUD_1.xml"), "w") as handle: + handle.write( + audit_xml( + doc.name, + [ + (self.slips[0].name, "Authorised"), + (self.slips[1].name, "x"), + ], + ) + ) + with open(os.path.join(inbox, "FINAUD_1.xml"), "w") as handle: + handle.write(audit_xml(doc.name, [(self.slips[0].name, "Paid")])) + with open(os.path.join(inbox, "ACK_missing.xml"), "w") as handle: + handle.write(ack_xml("NO-SUCH-DOC")) + with open(os.path.join(inbox, "notes.txt"), "w") as handle: + handle.write("ignored") + + sftp.process_download_files() + + doc.reload() + self.assertEqual(doc.stanbic_ack_status, "ACTC Accepted") + self.assertEqual(doc.stanbic_ack_change, 0) + self.assertEqual(doc.stanbic_intaud_change, 0) + self.assertEqual(doc.stanbic_finaud_change, 0) + self.assertEqual( + json.loads(doc.stanbic_ack)["Document"]["CstmrPmtStsRpt"]["OrgnlGrpInfAndSts"]["GrpSts"], "ACTC" + ) + statuses = { + row.salary_slip: (row.stanbic_intaud_status, row.stanbic_finaud_status) + for row in doc.stanbic_payments_info + } + self.assertEqual(statuses[self.slips[0].name], ('"Authorised"', '"Paid"')) + self.assertEqual(statuses[self.slips[1].name], ('"STATUS NOT FOUND"', None)) + + sftp.process_download_files() + doc.reload() + self.assertEqual(doc.stanbic_ack_status, "ACTC Accepted") + + def test_parse_xml(self): + path = os.path.join(self.temp_dir, "sample.xml") + with open(path, "w") as handle: + handle.write(ack_xml("MSG-1", info="Fine")) + parsed = parse_xml(path) + self.assertEqual(parsed["Document"]["CstmrPmtStsRpt"]["OrgnlGrpInfAndSts"]["OrgnlMsgId"], "MSG-1") + + +class TestStanbicSftp(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.key = make_pgp_key() + cls.setting = make_stanbic_setting(str(cls.key.pubkey)) + cls.temp_dir = tempfile.mkdtemp(prefix="csf_tz_sftp_") + + def make_client(self): + with patch.object(sftp, "paramiko") as paramiko: + client = sftp.Paramiko("host", "user", "/tmp/key.pem", 2222) + ssh = paramiko.SSHClient.return_value + ssh.connect.assert_called_once() + self.assertEqual(ssh.connect.call_args.args, ("host",)) + self.assertEqual(ssh.connect.call_args.kwargs["port"], 2222) + paramiko.RSAKey.from_private_key_file.assert_called_once_with("/tmp/key.pem") + return client, ssh + + def test_download_and_cleanup(self): + client, ssh = self.make_client() + local = os.path.join(self.temp_dir, "inbox") + remote_sftp = ssh.open_sftp.return_value + remote_sftp.listdir.return_value = ["ACK_1.xml"] + + def fake_get(remote_path, local_path): + with open(local_path, "w") as handle: + handle.write(remote_path) + + remote_sftp.get.side_effect = fake_get + self.assertEqual(client.download("/Inbox", local, cleanup=True), ["ACK_1.xml"]) + remote_sftp.remove.assert_called_once_with("/Inbox/ACK_1.xml") + remote_sftp.close.assert_called_once() + + remote_sftp.listdir.side_effect = OSError("no connection") + with self.assertRaisesRegex(frappe.ValidationError, "no connection"): + client.download("/Inbox", local) + + def test_upload_execute_and_close(self): + client, ssh = self.make_client() + local = os.path.join(self.temp_dir, "outbox") + os.makedirs(local, exist_ok=True) + with open(os.path.join(local, "pain.xml"), "w") as handle: + handle.write("") + remote_sftp = ssh.open_sftp.return_value + self.assertEqual(client.upload(local, "/Outbox", cleanup=True), ["pain.xml"]) + remote_sftp.put.assert_called_once_with(os.path.join(local, "pain.xml"), "/Outbox/pain.xml") + self.assertEqual(os.listdir(local), []) + + remote_sftp.put.side_effect = OSError("denied") + with open(os.path.join(local, "pain.xml"), "w") as handle: + handle.write("") + with self.assertRaisesRegex(frappe.ValidationError, "denied"): + client.upload(local, "/Outbox") + + stdout, stderr = MagicMock(), MagicMock() + stdout.read.return_value = b"ok" + stderr.read.return_value = b"" + ssh.exec_command.return_value = (MagicMock(), stdout, stderr) + self.assertEqual(client.execute("ls"), ("ok", "")) + client.close() + ssh.close.assert_called_once() + + def test_sync_uses_settings(self): + with patch.object(sftp, "Paramiko") as paramiko_class: + instance = paramiko_class.return_value + instance.upload.return_value = ["up.xml"] + instance.download.return_value = ["down.xml"] + self.assertEqual(sftp.sync_stanbank_files(self.setting.name), (["up.xml"], ["down.xml"])) + key_path = sftp.get_absolute_path("/private/files/stanbic_test_key.pem") + paramiko_class.assert_called_with("sftp.example.com", "wasco", key_path, 2222) + self.assertEqual(instance.upload.call_args.args[1], "/Outbox") + self.assertEqual(instance.download.call_args.args[0], "/Inbox") + self.assertEqual(instance.close.call_count, 2) + + with patch.object(sftp, "sync_stanbank_files") as sync: + sftp.sync_all_stanbank_files() + self.assertIn(self.setting.name, [call.args[0] for call in sync.call_args_list]) + + def test_paths(self): + site_root = os.path.join(frappe.utils.get_bench_path(), "sites", frappe.local.site) + self.assertEqual(sftp.get_absolute_path("/files/a.txt"), f"{site_root}/public//files/a.txt") + self.assertEqual(sftp.get_absolute_path("/private/files/a.txt"), f"{site_root}/private/files/a.txt") + self.assertEqual(sftp.get_absolute_path("/tmp/a.txt"), "/tmp/a.txt") + self.assertEqual(sftp.get_local_path(["private", "files", "x"]), f"{site_root}/private/files/x") + self.assertEqual(sftp.get_local_path(), "/") + self.assertEqual(sftp.get_site_path(), frappe.get_site_path("private", "files")) + path = os.path.join(self.temp_dir, "new", "dir") + sftp.create_dir_if_not_exists(path) + self.assertTrue(os.path.isdir(path)) From efb595fbb07f23c8fa60da5a809b0226fc6f95e1 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:14 +0300 Subject: [PATCH 39/74] test: assert the app patches are idempotent --- csf_tz/tests/test_patches_misc.py | 191 ++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 csf_tz/tests/test_patches_misc.py diff --git a/csf_tz/tests/test_patches_misc.py b/csf_tz/tests/test_patches_misc.py new file mode 100644 index 00000000..6d78cfc1 --- /dev/null +++ b/csf_tz/tests/test_patches_misc.py @@ -0,0 +1,191 @@ +from unittest.mock import patch + +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_field +from frappe.tests import IntegrationTestCase + +from csf_tz.patches import ( + add_custom_field_for_cusomer_suppliers_groups, + add_custom_fields_for_employee_advance, + add_custom_fields_for_sales_invoice_item_and_purchase_invoice_item, + add_custom_fields_on_customer_for_auto_close_dn, + create_the_stock_entry_type, + delete_default_value_fields, + disable_signup_in_website_settings, + fix_module_for_core_reports, + remove_deleted_modules_metadata, + remove_education_doctypes, + remove_ot_component_custom_fields, + remove_stock_entry_qty_field, + update_payware_settings_values_to_csf_tz_settings, + update_salary_slips_from_currrent_employee_payroll_cost_center, +) +from csf_tz.patches.custom_fields import ( + attendance_overtime_calculation_custom_fields, + create_custom_fields_for_additional_salary, + custom_fields_for_removed_edu_fields_in_csf_tz, + delete_employee_custom_fields, + payroll_approval_custom_fields, + payroll_cost_center_custom_fields, +) +from csf_tz.tests.integration_fixtures import make_payroll_entry_stub, make_salary_slip_stub + +CUSTOM_FIELD_PATCHES = { + custom_fields_for_removed_edu_fields_in_csf_tz: ["Account-item", "Address-tax_category"], + add_custom_fields_for_sales_invoice_item_and_purchase_invoice_item: [ + "Sales Invoice Item-csf_tz_wtax_jv_created", + "Purchase Invoice Item-csf_tz_create_wtax_entry", + ], + add_custom_fields_on_customer_for_auto_close_dn: [ + "Customer-csf_tz_is_auto_close_dn", + "Customer-csf_tz_close_dn_after", + ], + create_custom_fields_for_additional_salary: [ + "Additional Salary-based_on_hourly_rate", + "Additional Salary-auto_created_based_on", + ], + payroll_approval_custom_fields: [ + "Payroll Entry-has_payroll_approval", + "Salary Slip-has_payroll_approval", + ], + attendance_overtime_calculation_custom_fields: [ + "Employee-overtime_applicable", + "Attendance-overtime_applicable", + ], + add_custom_fields_for_employee_advance: ["Employee Advance-from_date", "Employee Advance-to_date"], + add_custom_field_for_cusomer_suppliers_groups: [ + "Customer Group-tax_category", + "Supplier Group-tax_category", + ], + payroll_cost_center_custom_fields: ["Salary Slip-payroll_cost_center"], +} + + +class TestCustomFieldPatches(IntegrationTestCase): + def test_custom_field_patches_are_idempotent(self): + for module, expected_fields in CUSTOM_FIELD_PATCHES.items(): + with self.subTest(patch=module.__name__): + module.execute() + count = frappe.db.count("Custom Field") + module.execute() + self.assertEqual(frappe.db.count("Custom Field"), count) + for name in expected_fields: + self.assertTrue(frappe.db.exists("Custom Field", name), name) + + def test_delete_employee_custom_fields(self): + with patch.object(frappe.db, "commit"): + delete_employee_custom_fields.execute() + delete_employee_custom_fields.execute() + self.assertFalse(frappe.db.get_value("Custom Field", "Employee-bank_country_code", "options")) + + def test_remove_ot_component_custom_fields(self): + create_custom_field( + "Employee", {"fieldname": "overtime_components", "fieldtype": "Data", "label": "OT Components"} + ) + self.assertTrue(frappe.db.exists("Custom Field", "Employee-overtime_components")) + remove_ot_component_custom_fields.execute() + remove_ot_component_custom_fields.execute() + self.assertFalse(frappe.db.exists("Custom Field", "Employee-overtime_components")) + self.assertFalse(frappe.db.exists("DocType", "Employee OT Component")) + + def test_remove_stock_entry_qty_field(self): + remove_stock_entry_qty_field.execute() + remove_stock_entry_qty_field.execute() + self.assertFalse(frappe.db.exists("Custom Field", "Stock Entry-qty")) + self.assertNotIn("qty", frappe.db.get_table_columns("Stock Entry")) + + +class TestDataPatches(IntegrationTestCase): + def test_create_the_stock_entry_type_is_idempotent(self): + create_the_stock_entry_type.execute() + create_the_stock_entry_type.execute() + self.assertEqual(frappe.db.get_value("Stock Entry Type", "To Company", "purpose"), "Material Receipt") + self.assertEqual(frappe.db.get_value("Stock Entry Type", "From Company", "purpose"), "Material Issue") + self.assertEqual( + frappe.db.count("Stock Entry Type", {"name": ["in", ["To Company", "From Company"]]}), 2 + ) + + def test_fix_module_for_core_reports(self): + frappe.db.set_value("Report", "Stock Ageing", "module", "Accounts", update_modified=False) + with patch.dict(frappe.flags, {"in_patch": True}): + fix_module_for_core_reports.execute() + fix_module_for_core_reports.execute() + self.assertEqual(frappe.db.get_value("Report", "Stock Ageing", "module"), "Stock") + self.assertEqual(frappe.db.get_value("Report", "Total Stock Summary", "module"), "Stock") + + def test_delete_default_value_fields(self): + frappe.defaults.set_global_default("year_start_date", "2026-01-01") + self.assertTrue(frappe.db.exists("DefaultValue", {"defkey": "year_start_date"})) + delete_default_value_fields.execute() + delete_default_value_fields.execute() + self.assertFalse(frappe.db.exists("DefaultValue", {"defkey": "year_start_date"})) + + def test_disable_signup_in_website_settings(self): + frappe.db.set_single_value("Website Settings", "disable_signup", 0) + disable_signup_in_website_settings.execute() + disable_signup_in_website_settings.execute() + self.assertEqual(frappe.db.get_single_value("Website Settings", "disable_signup"), 1) + + def test_update_salary_slips_from_employee_payroll_cost_center(self): + payroll_cost_center_custom_fields.execute() + payroll_entry = make_payroll_entry_stub() + slip = make_salary_slip_stub(payroll_entry, "_T-Employee-00001", 100) + frappe.db.set_value("Employee", "_T-Employee-00001", "payroll_cost_center", "_Test Cost Center - _TC") + frappe.db.set_value("Salary Slip", slip.name, "payroll_cost_center", None) + + update_salary_slips_from_currrent_employee_payroll_cost_center.execute() + update_salary_slips_from_currrent_employee_payroll_cost_center.execute() + self.assertEqual( + frappe.db.get_value("Salary Slip", slip.name, "payroll_cost_center"), "_Test Cost Center - _TC" + ) + + def test_update_payware_settings_skips_without_payware(self): + self.assertFalse(frappe.db.exists("DocType", "Payware Settings")) + with patch("frappe.get_doc") as get_doc: + update_payware_settings_values_to_csf_tz_settings.execute() + get_doc.assert_not_called() + + def test_inline_report_deletion_patch(self): + frappe.delete_doc_if_exists("Report", "Stock Ledger Mismatch") + self.assertFalse(frappe.db.exists("Report", "Stock Ledger Mismatch")) + + +class TestModulePatches(IntegrationTestCase): + def test_remove_education_doctypes(self): + remove_education_doctypes.execute() + with ( + patch("frappe.get_installed_apps", return_value=["frappe", "edu_tz"]), + patch("frappe.delete_doc") as delete_doc, + ): + remove_education_doctypes.execute() + delete_doc.assert_not_called() + with ( + patch("frappe.db.get_value", return_value="CSF TZ"), + patch("frappe.db.count", return_value=1), + patch("frappe.delete_doc") as delete_doc, + ): + remove_education_doctypes.execute() + delete_doc.assert_not_called() + with ( + patch("frappe.db.get_value", return_value="CSF TZ"), + patch("frappe.db.count", return_value=0), + patch("frappe.delete_doc") as delete_doc, + ): + remove_education_doctypes.execute() + self.assertEqual(delete_doc.call_count, 2) + + def test_remove_deleted_modules_metadata(self): + remove_deleted_modules_metadata.execute() + frappe.get_doc( + {"doctype": "Module Def", "module_name": "Fleet Management", "app_name": "csf_tz"} + ).insert() + remove_deleted_modules_metadata.execute() + self.assertFalse(frappe.db.exists("Module Def", "Fleet Management")) + remove_deleted_modules_metadata.execute() + + def test_remove_doctype_metadata_without_table(self): + with patch.object(frappe.db, "sql_ddl") as sql_ddl: + remove_deleted_modules_metadata.remove_doctype_metadata("No Such CSF DocType") + sql_ddl.assert_not_called() + remove_deleted_modules_metadata.remove_module_records(set()) + remove_deleted_modules_metadata.remove_doctypes(set()) From fdef9c81aea3f1d6610c40a5240f30d99fa4fed6 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:19 +0300 Subject: [PATCH 40/74] test: cover bank charge journals on payment entries --- csf_tz/tests/test_payments_bank_charges.py | 99 ++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 csf_tz/tests/test_payments_bank_charges.py diff --git a/csf_tz/tests/test_payments_bank_charges.py b/csf_tz/tests/test_payments_bank_charges.py new file mode 100644 index 00000000..87a71d61 --- /dev/null +++ b/csf_tz/tests/test_payments_bank_charges.py @@ -0,0 +1,99 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import nowdate + +from csf_tz.tests.import_fixtures import COMPANY, INR_BANK, INR_SUPPLIER + +BANK = "_Test Import Bank" + + +class TestBankChargesDoctypes(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.get_doc({"doctype": "Bank", "bank_name": BANK}).insert(ignore_if_duplicate=True) + cls.bank_account = frappe.get_doc( + { + "doctype": "Bank Account", + "account_name": "_Test Charges Account", + "bank": BANK, + "is_company_account": 1, + "company": COMPANY, + "account": INR_BANK, + "bank_supplier": INR_SUPPLIER, + } + ).insert() + if not frappe.db.exists("Item", "Bank Charges"): + frappe.get_doc( + { + "doctype": "Item", + "item_code": "Bank Charges", + "item_name": "Bank Charges", + "item_group": "_Test Item Group", + "stock_uom": "_Test UOM", + "is_stock_item": 0, + "item_defaults": [ + { + "company": COMPANY, + "expense_account": "Bank Charges - _TC", + "default_warehouse": "_Test Warehouse - _TC", + } + ], + } + ).insert() + + def make_bank_charges(self, *debits): + return frappe.get_doc( + { + "doctype": "CSF TZ Bank Charges", + "bank_account": self.bank_account.name, + "currency": "INR", + "bank_supplier": INR_SUPPLIER, + "exchange_rate": 1, + "account": INR_BANK, + "posting_date": nowdate(), + "company": COMPANY, + "csf_tz_bank_charges_detail": [ + {"value_date": nowdate(), "debit_amount": amount, "reference_number": f"REF-{index}"} + for index, amount in enumerate(debits) + ], + } + ) + + def test_total_is_sum_of_positive_debits(self): + charges = self.make_bank_charges(100, 50, -20) + charges.insert() + self.assertEqual(charges.total_bank_charges, 150) + self.assertTrue(charges.name.startswith("CTBC-")) + + def test_submit_creates_payments_and_invoice(self): + charges = self.make_bank_charges(100, 50) + charges.insert() + charges.submit() + payments = frappe.get_all( + "Payment Entry", + filters={"party": INR_SUPPLIER, "reference_no": ["in", ["REF-0", "REF-1"]], "docstatus": 1}, + fields=["name", "paid_amount", "paid_from"], + ) + self.assertEqual(sorted(row.paid_amount for row in payments), [50, 100]) + self.assertEqual({row.paid_from for row in payments}, {INR_BANK}) + self.assertEqual({row.ref_doctype for row in charges.csf_tz_bank_charges_detail}, {"Payment Entry"}) + self.assertEqual( + {row.ref_docname for row in charges.csf_tz_bank_charges_detail}, {p.name for p in payments} + ) + invoice = frappe.get_doc("Purchase Invoice", charges.ref_pi) + self.assertEqual(invoice.docstatus, 1) + self.assertEqual(invoice.supplier, INR_SUPPLIER) + self.assertEqual(invoice.items[0].item_code, "Bank Charges") + self.assertEqual(invoice.grand_total, 150) + + def test_bank_charges_pattern(self): + pattern = frappe.get_doc( + { + "doctype": "Bank Charges Pattern", + "bank_account": self.bank_account.name, + "bank_charges_pattern": "CHG", + } + ).insert() + self.assertTrue(pattern.name.startswith("CSFTZ-BCP-")) + self.assertEqual(pattern.bank_charges_pattern, "CHG") From 01884d616a05b466f1d9acbb950e16368f448113 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:23 +0300 Subject: [PATCH 41/74] test: cover the payment entry hooks and outstanding lookups --- csf_tz/tests/test_payments_entry.py | 189 ++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 csf_tz/tests/test_payments_entry.py diff --git a/csf_tz/tests/test_payments_entry.py b/csf_tz/tests/test_payments_entry.py new file mode 100644 index 00000000..4e0d4532 --- /dev/null +++ b/csf_tz/tests/test_payments_entry.py @@ -0,0 +1,189 @@ +import json + +import frappe +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, flt, getdate, nowdate + +from csf_tz.csftz_hooks.customer import get_customer_total_unpaid_amount +from csf_tz.csftz_hooks.payment_entry import ( + get_outstanding_reference_documents, + get_outstanding_sales_orders, +) +from csf_tz.tests.import_fixtures import ( + COMPANY, + INR_BANK, + INR_SUPPLIER, + ORIGINAL_RATE, + USD_SUPPLIER, + make_foreign_purchase_invoice, + make_plain_supplier_payment, + make_purchase_invoice, +) + +BANK_CHARGES_ACCOUNT = "Bank Charges - _TC" +CUSTOMER = "_Test Customer" + + +class TestPaymentEntryHooks(IntegrationTestCase): + def set_company(self, **values): + for field, value in values.items(): + original = frappe.db.get_value("Company", COMPANY, field) + frappe.db.set_value("Company", COMPANY, field, value) + self.addCleanup(frappe.db.set_value, "Company", COMPANY, field, original) + frappe.clear_document_cache("Company", COMPANY) + self.addCleanup(frappe.clear_document_cache, "Company", COMPANY) + + def test_unallocated_amount_is_restricted(self): + self.set_company(restrict_unallocated_amount_for_supplier=1) + payment = make_plain_supplier_payment(INR_SUPPLIER, 100, restrict_unallocated_amount_for_supplier=1) + with self.assertRaisesRegex(frappe.ValidationError, "unallocated amount"): + payment.insert() + payment.restrict_unallocated_amount_for_supplier = 0 + payment.insert() + self.assertEqual(payment.unallocated_amount, 100) + + def test_unallocated_amount_allowed_when_company_flag_is_off(self): + self.set_company(restrict_unallocated_amount_for_supplier=0) + payment = make_plain_supplier_payment(INR_SUPPLIER, 100, restrict_unallocated_amount_for_supplier=1) + payment.insert() + self.assertEqual(payment.unallocated_amount, 100) + + def test_bank_charges_require_company_account(self): + self.set_company(default_bank_charges_account=None) + payment = make_plain_supplier_payment(INR_SUPPLIER, 100, bank_charges=10) + payment.insert() + with self.assertRaisesRegex(frappe.ValidationError, "Default Bank Charges Account"): + payment.submit() + + def test_bank_charges_journal_entry_on_submit(self): + self.set_company(default_bank_charges_account=BANK_CHARGES_ACCOUNT) + payment = make_plain_supplier_payment(INR_SUPPLIER, 100, bank_charges=15) + payment.insert() + payment.submit() + self.assertTrue(payment.bank_charges_journal_entry) + journal_entry = frappe.get_doc("Journal Entry", payment.bank_charges_journal_entry) + self.assertEqual(journal_entry.docstatus, 1) + self.assertEqual(journal_entry.voucher_type, "Bank Entry") + self.assertEqual(journal_entry.cheque_no, payment.name) + self.assertIn(payment.name, journal_entry.user_remark) + amounts = {row.account: (row.debit, row.credit) for row in journal_entry.accounts} + self.assertEqual(amounts[BANK_CHARGES_ACCOUNT], (15, 0)) + self.assertEqual(amounts[INR_BANK], (0, 15)) + + def test_no_journal_entry_without_bank_charges(self): + payment = make_plain_supplier_payment(INR_SUPPLIER, 100) + payment.insert() + payment.submit() + self.assertFalse(payment.bank_charges_journal_entry) + + def supplier_args(self, supplier=INR_SUPPLIER, party_account="Creditors - _TC", **extra): + args = { + "party_type": "Supplier", + "party": supplier, + "party_account": party_account, + "company": COMPANY, + } + args.update(extra) + return args + + def test_get_outstanding_reference_documents(self): + invoice = make_purchase_invoice(supplier=INR_SUPPLIER, rate=100, qty=2) + rows = get_outstanding_reference_documents(json.dumps(self.supplier_args())) + row = next(r for r in rows if r.get("voucher_no") == invoice.name) + self.assertEqual(row["exchange_rate"], 1) + self.assertEqual(getdate(row["posting_date"]), getdate(invoice.posting_date)) + self.assertIn("bill_no", row) + self.assertEqual(row["outstanding_amount"], invoice.outstanding_amount) + + def test_get_outstanding_reference_documents_filters(self): + invoice = make_purchase_invoice(supplier=INR_SUPPLIER, rate=100, qty=2) + rows = get_outstanding_reference_documents( + self.supplier_args(voucher_type="Purchase Invoice", voucher_no=invoice.name) + ) + self.assertEqual([r.get("voucher_no") for r in rows], [invoice.name]) + later = self.supplier_args( + from_posting_date=add_days(nowdate(), 1), to_posting_date=add_days(nowdate(), 2) + ) + self.assertNotIn( + invoice.name, [r.get("voucher_no") for r in get_outstanding_reference_documents(later)] + ) + + def test_get_outstanding_reference_documents_foreign_currency(self): + invoice = make_foreign_purchase_invoice() + rows = get_outstanding_reference_documents( + self.supplier_args(USD_SUPPLIER, "_Test Payable USD - _TC") + ) + row = next(r for r in rows if r.get("voucher_no") == invoice.name) + self.assertEqual(row["exchange_rate"], ORIGINAL_RATE) + + def test_get_outstanding_reference_documents_disabled_or_member(self): + make_purchase_invoice(supplier=INR_SUPPLIER, rate=100) + frappe.db.set_single_value("CSF TZ Settings", "disable_get_outstanding_functionality", 1) + self.addCleanup( + frappe.db.set_single_value, "CSF TZ Settings", "disable_get_outstanding_functionality", 0 + ) + self.assertEqual(get_outstanding_reference_documents(self.supplier_args()), []) + frappe.db.set_single_value("CSF TZ Settings", "disable_get_outstanding_functionality", 0) + self.assertIsNone(get_outstanding_reference_documents({"party_type": "Member"})) + + def test_get_outstanding_reference_documents_blocked_supplier(self): + make_purchase_invoice(supplier=INR_SUPPLIER, rate=100) + self.addCleanup(frappe.db.set_value, "Supplier", INR_SUPPLIER, {"on_hold": 0, "hold_type": ""}) + frappe.db.set_value("Supplier", INR_SUPPLIER, {"on_hold": 1, "hold_type": "All"}) + self.assertEqual(get_outstanding_reference_documents(self.supplier_args()), []) + frappe.db.set_value("Supplier", INR_SUPPLIER, {"hold_type": "Payments", "release_date": None}) + self.assertEqual(get_outstanding_reference_documents(self.supplier_args()), []) + frappe.db.set_value("Supplier", INR_SUPPLIER, {"hold_type": "Invoices"}) + self.assertTrue(get_outstanding_reference_documents(self.supplier_args())) + + def test_get_outstanding_sales_orders(self): + order = make_sales_order(customer=CUSTOMER, qty=2, rate=100) + args = { + "party_type": "Customer", + "party": CUSTOMER, + "party_account": "Debtors - _TC", + "company": COMPANY, + "posting_date": nowdate(), + } + rows = get_outstanding_sales_orders(json.dumps(args)) + row = next(r for r in rows if r.get("voucher_no") == order.name) + self.assertEqual(row["voucher_type"], "Sales Order") + self.assertEqual(getdate(row["posting_date"]), getdate(order.transaction_date)) + self.assertEqual(getdate(row["due_date"]), getdate(order.delivery_date)) + self.assertEqual(row["exchange_rate"], 1) + with self.assertRaisesRegex(frappe.ValidationError, "only be fetched for Customer"): + get_outstanding_sales_orders(self.supplier_args()) + self.assertIsNone(get_outstanding_sales_orders({"party_type": "Member"})) + + def make_customer_debit(self, amount): + frappe.get_doc( + { + "doctype": "Journal Entry", + "company": COMPANY, + "posting_date": nowdate(), + "accounts": [ + { + "account": "Debtors - _TC", + "party_type": "Customer", + "party": CUSTOMER, + "debit_in_account_currency": amount, + }, + { + "account": "Sales - _TC", + "cost_center": "_Test Cost Center - _TC", + "credit_in_account_currency": amount, + }, + ], + } + ).submit() + + def test_customer_total_unpaid_amount(self): + def unpaid(company=None): + return flt(str(get_customer_total_unpaid_amount(CUSTOMER, company)).replace(",", "")) + + before = unpaid(COMPANY) + self.make_customer_debit(100) + self.assertEqual(unpaid(COMPANY) - before, 100) + self.assertGreaterEqual(unpaid(), unpaid(COMPANY)) + self.assertEqual(get_customer_total_unpaid_amount(None), 0) From 1b13bad9ad6157bbf95e9925ac4c39f3312652d1 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:28 +0300 Subject: [PATCH 42/74] test: cover selcom, landed cost and the dashboard chart source --- csf_tz/tests/test_payments_misc.py | 240 +++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 csf_tz/tests/test_payments_misc.py diff --git a/csf_tz/tests/test_payments_misc.py b/csf_tz/tests/test_payments_misc.py new file mode 100644 index 00000000..b64fca94 --- /dev/null +++ b/csf_tz/tests/test_payments_misc.py @@ -0,0 +1,240 @@ +import json +from datetime import date +from unittest.mock import ANY, MagicMock, patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, getdate, nowdate + +from csf_tz.api.selcom import create_order_minimal +from csf_tz.api.utils import msgPrint, msgThrow +from csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline import ( + MultiBankBalance, + create_sample_accounts, + create_test_transactions, + debug_chart_data, + get, + get_account_currencies, + get_default_bank_account, + get_sample_data, + validate_chart_permissions, +) +from csf_tz.csftz_hooks.landed_cost_voucher import get_landed_cost_expenses, total_amount +from csf_tz.tests.import_fixtures import COMPANY, INR_BANK + +NO_ROLE_USER = "csf-payments-noperm@example.com" + + +class TestSelcomOrders(IntegrationTestCase): + def create_order(self, response=None, error=None): + client = MagicMock() + client.postFunc.return_value = response + client.postFunc.side_effect = error + with ( + patch("csf_tz.api.selcom.apigwClient.Client", return_value=client), + patch("csf_tz.api.selcom.create_order_log") as create_order_log, + ): + result = create_order_minimal() + client.postFunc.assert_called_once_with("/checkout/create-order-minimal", ANY) + return result, create_order_log + + def test_successful_order_is_logged(self): + response = {"resultcode": "000", "reference": "REF-1"} + result, create_order_log = self.create_order(response) + self.assertEqual(result, response) + self.assertEqual(create_order_log.call_args.args[1], "Success") + self.assertEqual(create_order_log.call_args.kwargs["reference"], "REF-1") + + def test_rejected_order_is_logged_as_failed(self): + result, create_order_log = self.create_order({"resultcode": "999", "reference": "REF-2"}) + self.assertEqual(result["resultcode"], "999") + self.assertEqual(create_order_log.call_args.args[1], "Failed") + + def test_gateway_error_raises(self): + with self.assertRaisesRegex(frappe.ValidationError, "Failed to create order"): + self.create_order(error=Exception("gateway down")) + + +class TestApiUtils(IntegrationTestCase): + def test_msg_throw(self): + with self.assertRaises(frappe.ValidationError): + msgThrow("blocked") + with patch("frappe.msgprint") as msgprint: + msgThrow("warned", "validate") + msgprint.assert_called_once_with("warned", alert=True) + + def test_msg_print(self): + with patch("frappe.msgprint") as msgprint: + msgPrint("validated", "validate") + msgPrint("thrown") + self.assertEqual(msgprint.call_args_list[0].kwargs, {"alert": True}) + self.assertEqual(msgprint.call_args_list[1].kwargs, {"alert": False}) + + +class TestLandedCostVoucherHooks(IntegrationTestCase): + def test_total_amount(self): + voucher = frappe.new_doc("Landed Cost Voucher") + voucher.append("items", {"amount": 100, "applicable_charges": 20}) + voucher.append("items", {"amount": 50, "applicable_charges": 0}) + total_amount(voucher, "validate") + self.assertEqual([row.custom_total_amount for row in voucher.items], [120, 0]) + self.assertEqual(voucher.custom_grand_total, 120) + voucher.items = [] + total_amount(voucher, "validate") + self.assertEqual(voucher.custom_grand_total, 0) + + def test_landed_cost_expenses_without_import_file(self): + self.assertIsNone(get_landed_cost_expenses()) + self.assertIsNone(get_landed_cost_expenses("")) + + +class TestMultiAccountBalanceTimeline(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + if not frappe.db.exists("User", NO_ROLE_USER): + frappe.get_doc( + {"doctype": "User", "email": NO_ROLE_USER, "first_name": "No Perm", "send_welcome_email": 0} + ).insert(ignore_permissions=True) + + def tearDown(self): + frappe.set_user("Administrator") + + def test_get_returns_dataset_per_bank_account(self): + data = get(filters=json.dumps({"company": COMPANY})) + accounts = MultiBankBalance().get_bank_accounts(COMPANY, "Bank") + self.assertEqual(data["account_count"], len(accounts)) + self.assertEqual(len(data["datasets"]), len(accounts)) + self.assertEqual(len(data["labels"]), 366) + self.assertIn("_Test Bank", {dataset["name"] for dataset in data["datasets"]}) + self.assertEqual(data["summary"]["account_count"], len(accounts)) + + def test_get_with_currency_filter(self): + data = get(filters={"company": COMPANY, "currency": "USD"}) + self.assertEqual({dataset["name"] for dataset in data["datasets"]}, {"_Test Bank USD"}) + data = get(filters={"company": COMPANY, "currency": "JPY"}) + self.assertTrue(data["empty"]) + self.assertIn("No bank accounts found", data["message"]) + + def test_get_without_company_returns_error_chart(self): + for filters in ({}, "not json"): + data = get(filters=filters) + self.assertTrue(data["empty"]) + self.assertIn("Error retrieving", data["message"]) + + def test_balances_follow_journal_entries(self): + posting_date = add_days(nowdate(), -3) + frappe.get_doc( + { + "doctype": "Journal Entry", + "company": COMPANY, + "posting_date": posting_date, + "accounts": [ + {"account": INR_BANK, "debit_in_account_currency": 1000}, + {"account": "Cash - _TC", "credit_in_account_currency": 1000}, + ], + } + ).submit() + from_date, to_date = getdate(add_days(nowdate(), -10)), getdate(nowdate()) + balances = MultiBankBalance().get_account_balances([{"name": INR_BANK}], from_date, to_date) + self.assertEqual(balances[to_date][INR_BANK] - balances[from_date][INR_BANK], 1000) + self.assertEqual( + balances[getdate(add_days(posting_date, -1))][INR_BANK], balances[from_date][INR_BANK] + ) + data = get(filters={"company": COMPANY}, from_date=from_date, to_date=to_date) + self.assertNotIn("empty", data) + + def test_filter_validation(self): + validate = MultiBankBalance().validate_and_process_filters + with self.assertRaisesRegex(frappe.ValidationError, "Company filter is required"): + validate({}) + with self.assertRaisesRegex(frappe.ValidationError, "Invalid company"): + validate({"company": "No Such Company"}) + self.assertEqual(validate({"company": COMPANY})["account_type"], "Bank") + self.assertEqual(validate({"company": COMPANY, "account_type": "Weird"})["account_type"], "Bank") + self.assertEqual(validate({"company": COMPANY, "account_type": "Cash"})["account_type"], "Cash") + + def test_date_range_validation(self): + chart = MultiBankBalance() + self.assertEqual( + chart.validate_date_range(None, None), (getdate(add_days(nowdate(), -365)), getdate()) + ) + with self.assertRaisesRegex(frappe.ValidationError, "From Date cannot be after"): + chart.validate_date_range("2026-02-01", "2026-01-01") + with self.assertRaisesRegex(frappe.ValidationError, "cannot exceed"): + chart.validate_date_range("2020-01-01", "2026-01-01") + + def test_date_range_intervals(self): + chart = MultiBankBalance() + self.assertEqual(len(chart.get_date_range(date(2026, 1, 1), date(2026, 1, 8))), 8) + self.assertEqual( + chart.get_date_range(date(2026, 1, 1), date(2026, 1, 20), "weekly"), + [date(2026, 1, 1), date(2026, 1, 8), date(2026, 1, 15), date(2026, 1, 20)], + ) + self.assertEqual( + chart.get_date_range(date(2026, 11, 15), date(2027, 2, 1), "monthly"), + [date(2026, 11, 15), date(2026, 12, 15), date(2027, 1, 15), date(2027, 2, 1)], + ) + + def test_format_chart_data_and_summary(self): + chart = MultiBankBalance() + accounts = [{"name": "A", "account_name": "Acc A"}, {"name": "B", "account_name": ""}] + balances = {date(2026, 1, 1): {"A": 100, "B": 50}, date(2026, 1, 2): {"A": 200, "B": 50}} + data = chart.format_chart_data(balances, accounts, date(2026, 1, 1), date(2026, 1, 2)) + self.assertEqual(data["labels"], ["Jan 1", "Jan 2"]) + self.assertEqual(data["datasets"][0]["values"], [100, 200]) + self.assertEqual(data["datasets"][1]["name"], "B") + self.assertEqual(data["summary"]["total_balance"], 250) + self.assertEqual(data["summary"]["highest_balance_account"], "Acc A") + self.assertEqual(data["summary"]["highest_balance"], 200) + self.assertTrue(chart.format_chart_data({}, accounts, None, None)["empty"]) + self.assertEqual(chart.calculate_summary_stats({}, accounts), {}) + + def test_sample_data(self): + for data in (get_sample_data(), MultiBankBalance().get_sample_data()): + self.assertTrue(data["is_sample_data"]) + self.assertEqual(len(data["datasets"]), 3) + self.assertEqual(len(data["labels"]), 31) + + def test_debug_chart_data(self): + result = debug_chart_data(COMPANY) + self.assertEqual(result["step"], "completed_successfully") + self.assertGreater(result["account_count"], 0) + + def test_colors_and_empty_chart(self): + chart = MultiBankBalance() + self.assertEqual(len(chart.get_chart_colors(12)), 12) + self.assertEqual(chart.get_chart_colors(0), ["#1f77b4"]) + self.assertEqual(chart.empty_chart_data(None)["message"], "No data available") + + def test_create_sample_accounts(self): + created = create_sample_accounts(COMPANY) + self.assertEqual(len(created), 3) + for account in created: + self.assertEqual(frappe.db.get_value("Account", account, "parent_account"), "Bank Accounts - _TC") + self.assertEqual(create_sample_accounts(COMPANY), []) + with self.assertRaisesRegex(frappe.ValidationError, "does not exist"): + create_sample_accounts("No Such Company") + + def test_create_test_transactions(self): + result = create_test_transactions(COMPANY, INR_BANK, 500) + self.assertTrue(result["success"]) + self.assertEqual([entry["amount"] for entry in result["created_entries"]], [500, 150]) + for entry in result["created_entries"]: + self.assertEqual(frappe.db.get_value("Journal Entry", entry["journal_entry"], "docstatus"), 1) + + def test_permissions_for_restricted_user(self): + frappe.set_user(NO_ROLE_USER) + with self.assertRaisesRegex(frappe.ValidationError, "permission"): + create_test_transactions(COMPANY) + with self.assertRaisesRegex(frappe.ValidationError, "permission"): + create_sample_accounts(COMPANY) + self.assertEqual(MultiBankBalance().get_bank_accounts(COMPANY), []) + self.assertIn("No bank accounts found", get(filters={"company": COMPANY})["message"]) + + def test_helpers(self): + self.assertEqual( + get_default_bank_account(COMPANY), frappe.db.get_value("Company", COMPANY, "default_bank_account") + ) + self.assertTrue({"INR", "USD"} <= set(get_account_currencies(COMPANY))) + self.assertTrue(validate_chart_permissions("Any Chart")) From bfdc8e92a2bebfa24c255904e76b02eade67eeba Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:33 +0300 Subject: [PATCH 43/74] test: cover the ledger, receivable and bank reports --- csf_tz/tests/test_reports_accounts.py | 242 ++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 csf_tz/tests/test_reports_accounts.py diff --git a/csf_tz/tests/test_reports_accounts.py b/csf_tz/tests/test_reports_accounts.py new file mode 100644 index 00000000..ad4c5c82 --- /dev/null +++ b/csf_tz/tests/test_reports_accounts.py @@ -0,0 +1,242 @@ +from unittest.mock import patch + +import frappe +from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, get_first_day, get_last_day, today + +from csf_tz.tests.report_fixtures import ( + BANK_ACCOUNT_GL, + COMPANY, + COST_CENTER, + as_dicts, + date_range, + fieldnames, + make_bank_account, + make_bank_transaction, + make_currency_exchange, + receive_stock, + run_report, + sell_stock, +) + + +class TestAccountsReports(IntegrationTestCase): + """Runs the accounting reports of csf_tz against freshly posted vouchers.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + receive_stock(qty=20, rate=100) + cls.sales_invoice = sell_stock(qty=2, rate=500) + cls.purchase_invoice = make_purchase_invoice(qty=5, rate=50, posting_date=today()) + cls.bank_account = make_bank_account() + cls.journal_entry = make_journal_entry( + BANK_ACCOUNT_GL, + "_Test Account Cost for Goods Sold - _TC", + 1000, + cost_center=COST_CENTER, + submit=True, + ) + cls.bank_transaction = make_bank_transaction(cls.bank_account, deposit=1000) + cls.credit_note = sell_stock(qty=-1, rate=500, is_return=1, return_against=cls.sales_invoice.name) + make_currency_exchange("USD", "TZS", 2500) + + def ageing_filters(self, **extra): + filters = { + "company": COMPANY, + "ageing_based_on": "Posting Date", + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } + filters.update(extra) + return filters + + def test_accounts_receivable_multi_currency(self): + columns, rows = run_report("Accounts Receivable Multi Currency", self.ageing_filters()) + rows = as_dicts(columns, rows) + invoice_rows = [r for r in rows if r.get("voucher_no") == self.sales_invoice.name] + self.assertEqual(len(invoice_rows), 1) + # ERPNext books a return either against the invoice or against itself, so compare the net + outstanding = frappe.db.get_value("Sales Invoice", self.sales_invoice.name, "outstanding_amount") + returned = sum( + frappe.get_all( + "Sales Invoice", + filters={"return_against": self.sales_invoice.name, "docstatus": 1}, + pluck="outstanding_amount", + ) + ) + self.assertEqual(outstanding + returned, 500) + self.assertEqual(invoice_rows[0]["outstanding"], 500) + + def test_accounts_receivable_summary_multi_currency(self): + columns, rows = run_report( + "Accounts Receivable Summary Multi Currency", self.ageing_filters(currency="INR") + ) + rows = as_dicts(columns, rows) + customer_rows = [r for r in rows if "_Test Customer" in r.values()] + self.assertEqual(len(customer_rows), 1, rows) + self.assertGreaterEqual( + customer_rows[0]["total_outstanding_amt"], self.sales_invoice.outstanding_amount + ) + + def ledger_filters(self, **extra): + filters = { + "company": COMPANY, + "from_date": add_days(today(), -1), + "to_date": today(), + "group_by": "Group by Voucher (Consolidated)", + "include_default_book_entries": 1, + } + filters.update(extra) + return filters + + def test_general_ledger_pro(self): + columns, rows = run_report("General Ledger Pro", self.ledger_filters()) + rows = as_dicts(columns, rows) + vouchers = {r.get("voucher_no") for r in rows} + self.assertIn(self.sales_invoice.name, vouchers) + self.assertIn(self.credit_note.name, vouchers) + columns, rows = run_report("General Ledger Pro", self.ledger_filters(account=BANK_ACCOUNT_GL)) + self.assertIn(self.journal_entry.name, {r.get("voucher_no") for r in as_dicts(columns, rows)}) + + def test_general_ledger_pro_presentation_currency(self): + with patch("erpnext.accounts.report.utils.get_rate_as_at", return_value=2): + columns, rows = run_report( + "General Ledger Pro", + self.ledger_filters(presentation_currency="USD", account="Debtors - _TC"), + ) + rows = as_dicts(columns, rows) + self.assertTrue(any(r.get("voucher_no") == self.sales_invoice.name for r in rows)) + + def test_multi_currency_ledger(self): + columns, rows = run_report("Multi-Currency Ledger", self.ledger_filters()) + rows = as_dicts(columns, rows) + self.assertIn("foreign_currency", fieldnames(columns)) + self.assertIn(self.journal_entry.name, {r.get("voucher_no") for r in rows}) + + def test_monthly_account_balance(self): + columns, rows = run_report("Monthly Account Balance", {"account": [BANK_ACCOUNT_GL]}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_trial_balance_report_in_usd(self): + columns, rows = run_report( + "Trial Balance Report in USD", + { + "company": COMPANY, + "fiscal_year": "_Test Fiscal Year 2026", + "from_date": get_first_day(today()), + "to_date": get_last_day(today()), + "with_period_closing_entry": 1, + }, + ) + rows = as_dicts(columns, rows) + debtors = [r for r in rows if r.get("account") == "Debtors - _TC"] + self.assertEqual(len(debtors), 1, rows) + self.assertAlmostEqual(debtors[0]["debit"], self.sales_invoice.grand_total / 2500, places=2) + + def test_gross_profit_pro_by_invoice(self): + columns, rows = run_report( + "Gross Profit Pro", + {"company": COMPANY, "group_by": "Invoice", **date_range()}, + ) + rows = as_dicts(columns, rows) + invoice_rows = [r for r in rows if r.get("sales_invoice") == self.sales_invoice.name] + self.assertEqual(len(invoice_rows), 1, rows) + self.assertEqual(invoice_rows[0]["qty"], 1) + self.assertEqual(invoice_rows[0]["selling_amount"], 500) + self.assertEqual(invoice_rows[0]["buying_amount"], 100) + self.assertEqual(invoice_rows[0]["gross_profit"], 400) + + def test_gross_profit_pro_by_item_group(self): + columns, rows = run_report( + "Gross Profit Pro", + {"company": COMPANY, "group_by": "Item Group", **date_range()}, + ) + self.assertIn("item_group", fieldnames(columns)) + self.assertTrue(rows) + + def bank_filters(self): + return {"bank_account": self.bank_account, **date_range()} + + def test_bank_ledger_summary(self): + columns, rows = run_report("Bank Ledger Summary", self.bank_filters()) + rows = as_dicts(columns, rows) + self.assertEqual(rows[0]["account"], BANK_ACCOUNT_GL) + self.assertGreaterEqual(rows[0]["deposit"], 1000) + + def test_bank_transaction_summary(self): + columns, rows = run_report("Bank Transaction Summary", self.bank_filters()) + rows = as_dicts(columns, rows) + self.assertEqual(rows[0]["account"], BANK_ACCOUNT_GL) + self.assertEqual(rows[0]["deposit"], 1000) + + def test_bank_trans_vs_gl_entry_report(self): + columns, rows = run_report("Bank Trans vs GL Entry Report", self.bank_filters()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_nmb_bank_charges_reports(self): + for report_name in ("NMB Bank Charges in Bank Transaction", "NMB Bank Transaction not Bank Charges"): + columns, rows = run_report(report_name, self.bank_filters()) + self.assertTrue(columns, report_name) + self.assertIsInstance(rows, list, report_name) + + def test_credit_note_list(self): + columns, rows = run_report("Credit Note List", date_range()) + self.assertTrue(any(self.credit_note.name in str(row) for row in rows)) + + def test_monthly_sales_and_purchase_summary(self): + year = today()[:4] + columns, rows = run_report("Monthly Sales Summary", {"year": year}) + self.assertTrue(columns) + self.assertTrue(rows) + columns, rows = run_report("Monthly Purchase Summary", {"year": year}) + self.assertTrue(columns) + self.assertTrue(rows) + + def test_gl_entry_summary_for_trading_account(self): + make_purchase_invoice( + qty=1, rate=70, posting_date=today(), expense_account="Cost of Goods Sold - _TC" + ) + columns, rows = run_report("GL Entry Summary for Trading Account", date_range()) + rows = as_dicts(columns, rows) + self.assertTrue( + any(r["voucher_type"] == "Main Purchase Invoice" and r["value"] == 70 for r in rows), rows + ) + + def test_import_exchange_differences(self): + columns, rows = run_report("Import Exchange Differences", {"company": COMPANY, **date_range()}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_report_permission(self): + user = frappe.get_doc( + { + "doctype": "User", + "email": "report-noroles@example.com", + "first_name": "No Roles", + "send_welcome_email": 0, + } + ).insert() + frappe.set_user(user.name) + try: + with self.assertRaises(frappe.PermissionError): + run_report("General Ledger Pro", self.ledger_filters()) + finally: + frappe.set_user("Administrator") + + def test_ledgers_with_user_permissions(self): + frappe.set_user("test@example.com") + try: + for report_name in ("General Ledger Pro", "Multi-Currency Ledger"): + columns, rows = run_report(report_name, self.ledger_filters()) + self.assertIn(self.journal_entry.name, {r.get("voucher_no") for r in as_dicts(columns, rows)}) + finally: + frappe.set_user("Administrator") From e4115a99a934fb9998f422d97568cab6ac62dee8 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:37 +0300 Subject: [PATCH 44/74] test: cover the salary register and statutory payroll reports --- csf_tz/tests/test_reports_payroll.py | 163 +++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 csf_tz/tests/test_reports_payroll.py diff --git a/csf_tz/tests/test_reports_payroll.py b/csf_tz/tests/test_reports_payroll.py new file mode 100644 index 00000000..f65bdb84 --- /dev/null +++ b/csf_tz/tests/test_reports_payroll.py @@ -0,0 +1,163 @@ +import frappe +from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet +from frappe.tests import IntegrationTestCase +from frappe.utils import add_months, get_first_day, get_last_day, today +from hrms.payroll.doctype.salary_slip.test_salary_slip import ( + make_deduction_salary_component, + make_earning_salary_component, +) +from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip +from hrms.payroll.doctype.salary_structure.test_salary_structure import ( + create_salary_structure_assignment, + make_salary_structure, +) + +from csf_tz.tests.report_fixtures import COMPANY, as_dicts, run_report +from csf_tz.utils.create_custom_fields import create_fields_from_json, load_json + +EMPLOYEE = "_T-Employee-00001" +STRUCTURE = "_Test Report Salary Structure" + + +def install_payroll_entry_cheque_fields(): + if not frappe.get_meta("Payroll Entry").has_field("cheque_number"): + create_fields_from_json(load_json("16_payroll_entry_cheque.json")) + frappe.clear_cache(doctype="Payroll Entry") + + +def make_submitted_salary_slip(): + """Submit a previous-month slip on base 40000 and a current-month slip on base 50000.""" + make_earning_salary_component(setup=True, company_list=[COMPANY]) + make_deduction_salary_component(setup=True, company_list=[COMPANY]) + frappe.db.set_single_value("Payroll Settings", "email_salary_slip_to_employee", 0) + frappe.db.set_value( + "Employee", EMPLOYEE, {"salary_mode": "Bank", "bank_name": "Test Bank", "bank_ac_no": "12345"} + ) + previous_month = add_months(today(), -1) + make_salary_structure( + STRUCTURE, + "Monthly", + employee=EMPLOYEE, + from_date=get_first_day(previous_month), + company=COMPANY, + base=40000, + ) + create_salary_structure_assignment( + EMPLOYEE, + STRUCTURE, + from_date=get_first_day(today()), + company=COMPANY, + base=50000, + allow_duplicate=True, + ) + slips = [] + for posting_date in (previous_month, today()): + slip = make_salary_slip(STRUCTURE, employee=EMPLOYEE, posting_date=posting_date) + slip.insert() + slip.submit() + slips.append(slip) + return slips[-1] + + +class TestPayrollReports(IntegrationTestCase): + """Runs the payroll and statutory reports of csf_tz against a submitted salary slip.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + install_payroll_entry_cheque_fields() + cls.slip = make_submitted_salary_slip() + cls.period = {"from_date": get_first_day(today()), "to_date": get_last_day(today())} + + def register_filters(self, **extra): + return { + "company": COMPANY, + "currency": "INR", + "docstatus": "Submitted", + **self.period, + **extra, + } + + def test_salary_register_csf(self): + columns, rows = run_report("Salary Register csf", self.register_filters()) + rows = as_dicts(columns, rows) + employee_rows = [r for r in rows if r.get("employee") == EMPLOYEE] + self.assertEqual(len(employee_rows), 1) + self.assertEqual(employee_rows[0]["net_pay"], self.slip.net_pay) + + def test_salary_register_ctc(self): + columns, rows = run_report("Salary Register CTC", self.register_filters()) + rows = as_dicts(columns, rows) + self.assertTrue(any(r.get("employee") == EMPLOYEE for r in rows)) + + def test_salary_register_summary(self): + columns, rows = run_report("Salary Register Summary", self.register_filters()) + self.assertTrue(columns) + self.assertTrue(rows) + + def test_salary_register_summary_with_components(self): + columns, rows = run_report("Salary Register Summary with Components", self.register_filters()) + self.assertTrue(columns) + self.assertTrue(rows) + + def test_salary_register_summary_with_monthly_comparison(self): + columns, rows = run_report( + "Salary Register Summary with Monthly Comparison", self.register_filters(based_on_department=1) + ) + rows = as_dicts(columns, rows) + self.assertTrue(any(r.get("total_cur_month") for r in rows)) + + def test_employee_salary_register_with_monthly_comparison(self): + columns, rows = run_report( + "Employee Salary Register with Monthly Comparison", self.register_filters() + ) + rows = as_dicts(columns, rows) + self.assertTrue(any(r.get("employee") == EMPLOYEE for r in rows), rows) + + def test_bank_report(self): + columns, rows = run_report("Bank Report", self.period) + self.assertIn("Cheque No.", [c["label"] for c in columns]) + rows = as_dicts(columns, rows) + employee_rows = [r for r in rows if r.get("employee_id") == EMPLOYEE] + self.assertEqual(len(employee_rows), 1, rows) + self.assertEqual(employee_rows[0]["net_pay"], self.slip.net_pay) + + def test_statutory_query_reports(self): + for report_name in ( + "HESLB Return online", + "ITX.215.03.E SDL Monthly Returns", + "ITX.219.03.E Statement of Tax Withheld", + "NSSF CON5 Monthly Contribution - Online Version", + "Payroll for Mobile Payment", + "WCF Employee", + ): + columns, rows = run_report(report_name, self.period) + self.assertTrue(columns, report_name) + self.assertIsInstance(rows, list, report_name) + + def test_paye_report_mapping(self): + columns, rows = run_report("PAYE Report Mapping", self.period) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_loan_repayment_details_needs_lending_app(self): + if frappe.db.exists("DocType", "Loan Repayment"): + self.skipTest("Lending app is installed") + with self.assertRaises(frappe.ValidationError): + run_report("Loan Repayment Details", {"employee": EMPLOYEE}) + + def test_loan_outstanding_needs_lending_app(self): + if frappe.db.exists("DocType", "Loan"): + self.skipTest("Lending app is installed") + with self.assertRaises(frappe.db.ProgrammingError): + run_report("Loan Outstanding", {}) + + def test_monthly_timesheet_report(self): + timesheet = make_timesheet(EMPLOYEE, company=COMPANY) + columns, rows = run_report("Monthly Timesheet Report", self.period) + rows = as_dicts(columns, rows) + self.assertTrue(any(r.get("employee_name") == timesheet.employee_name for r in rows)) + self.assertTrue(any(r.get("hours_used") == 2 for r in rows)) + columns, rows = run_report("Monthly Timesheet Report", {**self.period, "hours_per_day": 1}) + self.assertTrue(rows) From f95fab36ccf2877ebde581131e8110b3011e70fb Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:42 +0300 Subject: [PATCH 45/74] test: cover the sales and marketing reports --- csf_tz/tests/test_reports_sales.py | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 csf_tz/tests/test_reports_sales.py diff --git a/csf_tz/tests/test_reports_sales.py b/csf_tz/tests/test_reports_sales.py new file mode 100644 index 00000000..42d63934 --- /dev/null +++ b/csf_tz/tests/test_reports_sales.py @@ -0,0 +1,112 @@ +import frappe +from erpnext.selling.doctype.quotation.test_quotation import make_quotation +from frappe.tests import IntegrationTestCase +from frappe.utils import today + +from csf_tz.tests.report_fixtures import ( + COMPANY, + ITEM, + as_dicts, + date_range, + receive_stock, + run_report, + sell_stock, +) + + +def make_lead_quotation(): + lead = frappe.get_all("Lead", limit=1, pluck="name")[0] + quotation = frappe.get_doc( + { + "doctype": "Quotation", + "quotation_to": "Lead", + "party_name": lead, + "company": COMPANY, + "transaction_date": today(), + "items": [{"item_code": ITEM, "qty": 3, "rate": 100, "warehouse": "_Test Warehouse - _TC"}], + } + ).insert() + quotation.submit() + return quotation + + +class TestSalesReports(IntegrationTestCase): + """Runs the sales, marketing and system listing reports of csf_tz.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + receive_stock(qty=20, rate=100) + cls.sales_invoice = sell_stock(qty=2, rate=500) + cls.customer_quotation = make_quotation(qty=5, rate=100) + cls.lead_quotation = make_lead_quotation() + + def test_av_sales_invoice_trend(self): + columns, rows = run_report( + "AV Sales Invoice Trend", + { + "period": "Monthly", + "based_on": "Item", + "fiscal_year": "_Test Fiscal Year 2026", + "company": COMPANY, + }, + ) + self.assertEqual(columns[-1]["fieldname"], "warehouse") + rows = as_dicts(columns, rows) + item_rows = [r for r in rows if r.get("item") == ITEM] + self.assertEqual(len(item_rows), 1, rows) + self.assertGreater(item_rows[0]["total_available_qty"], 0) + + def test_brand_sales_report(self): + columns, rows = run_report("Brand Sales Report", date_range()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_customer_loan_assistance_report_needs_doctype(self): + if frappe.db.exists("DocType", "Customer Loan Assistance"): + self.skipTest("Customer Loan Assistance is installed") + with self.assertRaises(frappe.ValidationError): + run_report("Customer Loan Assistance report", date_range()) + + def test_items_marked_for_delivery_needs_custom_field(self): + if frappe.get_meta("Sales Invoice Item").has_field("is_marked"): + self.skipTest("is_marked field is installed") + with self.assertRaises(frappe.ValidationError): + run_report("Items Marked For Delivery", {}) + + def test_item_wise_leads_report(self): + columns, rows = run_report("Item Wise Leads Report", date_range()) + rows = as_dicts(columns, rows) + item_rows = [r for r in rows if r["item_code"] == ITEM] + self.assertEqual(len(item_rows), 1) + self.assertEqual(item_rows[0]["total_qty"], 8) + self.assertEqual(item_rows[0]["quotations"], 2) + self.assertEqual(item_rows[0]["customers"], 1) + self.assertEqual(item_rows[0]["leads"], 1) + + def test_previous_ams_customer_report(self): + columns, rows = run_report("Previous Ams Customer Report", date_range()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_sales_details_report(self): + columns, rows = run_report("Sales Details Report", date_range()) + self.assertTrue(any(self.sales_invoice.name in str(row) for row in rows)) + + def test_spare_sales_report(self): + columns, rows = run_report("Spare Sales Report", date_range()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_parent_child_relationship(self): + columns, rows = run_report( + "Parent Child Relationship", {"is_submittable": 1, "is_table": 0, "module": "Accounts"} + ) + self.assertTrue(any("Accounts-Sales Invoice" in str(row) for row in rows)) + + def test_role_and_user_listing(self): + columns, rows = run_report("Role Permission Listing", {}) + self.assertTrue(rows) + columns, rows = run_report("User Role Listing", {}) + self.assertTrue(any("Administrator" in str(row) for row in rows)) From 84ca0f03cc38445e1b0fc68b2c4e582767bdc541 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:47 +0300 Subject: [PATCH 46/74] test: cover the stock balance and movement reports --- csf_tz/tests/test_reports_stock.py | 146 +++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 csf_tz/tests/test_reports_stock.py diff --git a/csf_tz/tests/test_reports_stock.py b/csf_tz/tests/test_reports_stock.py new file mode 100644 index 00000000..be15a1de --- /dev/null +++ b/csf_tz/tests/test_reports_stock.py @@ -0,0 +1,146 @@ +import frappe +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.material_request.test_material_request import make_material_request +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from csf_tz.tests.report_fixtures import ( + COMPANY, + ITEM, + WAREHOUSE, + as_dicts, + date_range, + fieldnames, + receive_stock, + run_report, + sell_stock, +) + + +class TestStockReports(IntegrationTestCase): + """Runs the stock and procurement reports of csf_tz against fresh stock movements.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + cls.stock_entry = receive_stock(qty=30, rate=100) + cls.sales_invoice = sell_stock(qty=2, rate=500) + cls.sales_order = make_sales_order(qty=4, rate=500) + cls.purchase_order = create_purchase_order(qty=6, rate=50) + cls.material_request = make_material_request(qty=3) + + def stock_filters(self, **extra): + return {"company": COMPANY, **date_range(), **extra} + + def item_row(self, columns, rows, key="item_code"): + return [r for r in as_dicts(columns, rows) if r.get(key) == ITEM] + + def test_csf_tz_stock_movement(self): + columns, rows = run_report("CSF TZ Stock Movement", self.stock_filters()) + self.assertTrue(columns) + self.assertTrue(rows) + + def test_itemwise_stock_movement(self): + columns, rows = run_report("Itemwise Stock Movement", self.stock_filters(warehouse=WAREHOUSE)) + self.assertTrue(columns) + self.assertTrue(rows) + + def test_stock_balance_pro(self): + columns, rows = run_report("Stock Balance Pro", self.stock_filters()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_stock_balance_pivot_warehouse(self): + columns, rows = run_report( + "Stock Balance pivot warehouse", {**date_range(), "filter_total_zero_qty": 1} + ) + self.assertIn(WAREHOUSE, [c["label"] for c in columns]) + rows = self.item_row(columns, rows) + self.assertEqual(len(rows), 1, rows) + self.assertEqual( + rows[0]["total_stock"], sum(v for k, v in rows[0].items() if k.startswith("_test_warehouse")) + ) + + def test_warehouse_wise_item_balance_and_value(self): + columns, rows = run_report( + "Warehouse wise Item Balance and Value", + {**date_range(), "company": COMPANY, "warehouse": WAREHOUSE, "filter_total_zero_qty": 1}, + ) + self.assertEqual(fieldnames(columns)[:4], ["item", "item_group", "value", "age"]) + rows = self.item_row(columns, rows, key="item") + self.assertEqual(len(rows), 1, rows) + self.assertGreater(rows[0]["value"], 0) + + def test_particular_item_history_report(self): + columns, rows = run_report("Particular Item History Report", {**date_range(), "warehouse": WAREHOUSE}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_item_price_by_price_list(self): + columns, rows = run_report("Item Price by Price List", {"tax_rate": 18}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_balance_below_safety_stock(self): + columns, rows = run_report("Balance below Safety Stock", {}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_stock_ledger_for_trading_account(self): + for report_name in ("Stock Ledger for Trading Account", "Stock Ledger Summary for Trading Account"): + columns, rows = run_report(report_name, date_range()) + self.assertTrue(columns, report_name) + self.assertTrue(rows, report_name) + + def test_stock_reconciliation_troubleshoot(self): + columns, rows = run_report("Stock Reconciliation troubleshoot", date_range()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_bin_system_report_builder(self): + report = frappe.get_doc("Report", "Bin System") + self.assertEqual((report.report_type, report.ref_doctype), ("Report Builder", "Bin")) + rows = frappe.get_list("Bin", filters={"item_code": ITEM}, fields=["warehouse", "actual_qty"]) + self.assertTrue(any(r["warehouse"] == WAREHOUSE for r in rows)) + + def test_ordered_items_to_be_delivered(self): + columns, rows = run_report("Ordered Items To Be Delivered", date_range()) + self.assertTrue(any(self.sales_order.name in str(row) for row in rows)) + + def test_pending_ordered_items(self): + columns, rows = run_report("Pending Ordered Items", date_range()) + self.assertTrue(any(self.purchase_order.name in str(row) for row in rows)) + + def test_purchase_history(self): + columns, rows = run_report("Purchase History", date_range()) + self.assertTrue(any(self.purchase_order.name in str(row) for row in rows)) + + def test_reordering_items(self): + columns, rows = run_report("Reordering Items", date_range()) + self.assertTrue(any(self.material_request.name in str(row) for row in rows)) + + def test_shipment_tracking(self): + order = frappe.get_doc( + { + "doctype": "Order Track", + "supplier": "_Test Supplier", + "supplier_type": "International Supplier", + "expected_arrival_date": add_days(today(), 5), + "shipped_date": today(), + "bl_number": "BL-1", + } + ).insert() + columns, rows = run_report( + "Shipment Tracking", {**date_range(), "supplier": "_Test Supplier", "order": order.name} + ) + rows = as_dicts(columns, rows) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["order_no"], order.name) + self.assertEqual(rows[0]["bl_number"], "BL-1") + + def test_supplier_contacts(self): + columns, rows = run_report("Supplier Contacts", {"party_type": "Supplier"}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) From 72501bb3706e1cc7ff429a7f1f27db686d0b02c6 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:52 +0300 Subject: [PATCH 47/74] test: cover the VAT, excise and withholding tax reports --- csf_tz/tests/test_reports_tax.py | 132 +++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 csf_tz/tests/test_reports_tax.py diff --git a/csf_tz/tests/test_reports_tax.py b/csf_tz/tests/test_reports_tax.py new file mode 100644 index 00000000..cee875ac --- /dev/null +++ b/csf_tz/tests/test_reports_tax.py @@ -0,0 +1,132 @@ +import frappe +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from frappe.tests import IntegrationTestCase +from frappe.utils import now_datetime, today + +from csf_tz.tests.report_fixtures import COMPANY, as_dicts, date_range, receive_stock, run_report, sell_stock + + +def make_efd_device(): + return frappe.get_doc( + { + "doctype": "Electronic Fiscal Device", + "type": "Electronic Fiscal Printer (EFP)", + "serial_no": "EFD-TEST-1", + "location": "Test Location", + "make": "TestMake", + "model": "M1", + } + ).insert() + + +def make_efd_z_report(device, z_no, sales_invoice=None): + invoices = [] + if sales_invoice: + invoices.append( + { + "invoice_number": sales_invoice.name, + "invoice_date": sales_invoice.posting_date, + "invoice_amount": sales_invoice.grand_total, + "amt_excl_vat": sales_invoice.net_total, + "vat": sales_invoice.total_taxes_and_charges, + "amt_ex__sr": 0, + "include": 1, + } + ) + return frappe.get_doc( + { + "doctype": "EFD Z Report", + "electronic_fiscal_device": device.name, + "z_no": z_no, + "receipts_issued": len(invoices), + "z_report_date_time": now_datetime(), + "efd_z_report_invoices": invoices, + } + ).insert() + + +def make_taxed_purchase_invoice(): + invoice = make_purchase_invoice(qty=5, rate=50, posting_date=today(), do_not_save=1) + invoice.bill_no = "SUP-INV-1" + invoice.bill_date = today() + invoice.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "description": "VAT", + "rate": 18, + "cost_center": "_Test Cost Center - _TC", + }, + ) + invoice.insert() + invoice.submit() + return invoice + + +class TestTaxReports(IntegrationTestCase): + """Runs the VAT, withholding and excise reports of csf_tz.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.set_user("Administrator") + receive_stock(qty=20, rate=100) + cls.sales_invoice = sell_stock(qty=2, rate=500) + cls.purchase_invoice = make_taxed_purchase_invoice() + cls.efd_device = make_efd_device() + + def test_output_vat_reconciliation(self): + efd_report = make_efd_z_report(self.efd_device, "Z-1", self.sales_invoice) + columns, rows = run_report("Output VAT Reconciliation", {"efd_report": efd_report.name}) + rows = as_dicts(columns, rows) + self.assertEqual(rows[0]["details"], "Sales - Sales Returns") + self.assertEqual(rows[1]["details"], self.sales_invoice.name) + self.assertEqual(rows[1]["invoice_currency"], "INR") + self.assertEqual(rows[-1]["details"], "Sales as VAT Returns") + + def test_output_vat_reconciliation_without_invoices(self): + columns, rows = run_report("Output VAT Reconciliation", {"efd_report": "EFD-MISSING"}) + self.assertEqual(rows, []) + + def test_withholding_tax_payment_summary_needs_lease_fields(self): + with self.assertRaises(frappe.ValidationError): + run_report("Withholding Tax Payment Summary", {"rental": "Commercial Rent"}) + + def test_withholding_tax_summary_on_sales(self): + columns, rows = run_report("Withholding Tax Summary on Sales", date_range()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_withholding_tax_upload(self): + columns, rows = run_report("Withholding Tax Upload", {}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_itx_230_withholding_tax_statement(self): + columns, rows = run_report("ITX 230.01.E – Withholding Tax Statement", date_range()) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_tra_input_vat_returns_efiling(self): + columns, rows = run_report("TRA Input VAT Returns eFiling", date_range()) + rows = as_dicts(columns, rows) + self.assertEqual(len(rows), 1, rows) + self.assertEqual(rows[0]["tax_invoice_number"], "SUP-INV-1") + self.assertEqual(rows[0]["vat_amt"], self.purchase_invoice.total_taxes_and_charges) + + def test_vat_efiling_returns(self): + columns, rows = run_report("VAT eFiling Returns", {"company": COMPANY, **date_range()}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) + + def test_excise_duty_reports(self): + for report_name in ("Excise Duty Report", "Excise Duty Detailed Report"): + columns, rows = run_report(report_name, date_range()) + self.assertTrue(columns, report_name) + self.assertIsInstance(rows, list, report_name) + + def test_excise_duty_stock(self): + columns, rows = run_report("Excise Duty Stock", {"company": COMPANY, **date_range()}) + self.assertTrue(columns) + self.assertIsInstance(rows, list) From 83997f20d2594fe8110d41c98e84150fb21fd916 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:58:57 +0300 Subject: [PATCH 48/74] test: cover the sales and marketing doctypes --- csf_tz/tests/test_sales_marketing_doctypes.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 csf_tz/tests/test_sales_marketing_doctypes.py diff --git a/csf_tz/tests/test_sales_marketing_doctypes.py b/csf_tz/tests/test_sales_marketing_doctypes.py new file mode 100644 index 00000000..355005c5 --- /dev/null +++ b/csf_tz/tests/test_sales_marketing_doctypes.py @@ -0,0 +1,112 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import today + +CUSTOMER = "_Test Customer" + + +def make_past_serial_no(serial_no, **values): + doc = frappe.get_doc( + { + "doctype": "Past Serial No", + "serial_no": serial_no, + "item_code": "_Test Item", + "customer": CUSTOMER, + "amount": 1500, + "date_of_sale": today(), + **values, + } + ) + return doc.insert() + + +class TestPastSerialNo(IntegrationTestCase): + def test_serial_no_is_the_document_name(self): + doc = make_past_serial_no("PSN-0001", payment_plan=[{"planned_date": today(), "planned_amount": 500}]) + self.assertEqual(doc.name, "PSN-0001") + self.assertEqual(doc.payment_plan[0].parent, "PSN-0001") + self.assertEqual(frappe.db.get_value("Past Serial No", "PSN-0001", "customer"), CUSTOMER) + + def test_serial_no_is_mandatory(self): + with self.assertRaises(frappe.ValidationError): + frappe.get_doc({"doctype": "Past Serial No", "item_code": "_Test Item"}).insert() + + def test_duplicate_serial_no_is_rejected(self): + make_past_serial_no("PSN-0002") + with self.assertRaises(frappe.DuplicateEntryError): + make_past_serial_no("PSN-0002") + + def test_unknown_customer_is_rejected(self): + with self.assertRaises(frappe.LinkValidationError): + make_past_serial_no("PSN-0003", customer="No Such Customer") + + def test_submit_and_cancel(self): + doc = make_past_serial_no("PSN-0004") + doc.submit() + self.assertEqual(doc.docstatus, 1) + doc.cancel() + self.assertEqual(frappe.db.get_value("Past Serial No", doc.name, "docstatus"), 2) + + +class TestPastSales(IntegrationTestCase): + def test_past_sale_links_to_past_serial_no(self): + serial = make_past_serial_no("PSN-0010") + sale = frappe.get_doc( + { + "doctype": "Past Sales", + "naming_series": "PS-", + "item_sold": "_Test Item", + "customer": CUSTOMER, + "amount": 1500, + "sold_date": today(), + "serial_no": serial.name, + "plate_no": "T 123 ABC", + } + ).insert() + self.assertTrue(sale.name.startswith("PS-")) + self.assertEqual(frappe.db.get_value("Past Sales", sale.name, "serial_no"), "PSN-0010") + + def test_unknown_serial_no_is_rejected(self): + with self.assertRaises(frappe.LinkValidationError): + frappe.get_doc({"doctype": "Past Sales", "naming_series": "PS-", "serial_no": "missing"}).insert() + + +class TestCommunications(IntegrationTestCase): + def test_communication_is_numbered_by_series(self): + doc = frappe.get_doc( + { + "doctype": "Communications", + "naming_series": "COMM-", + "type_of_communication": "Phone Call", + "customer": CUSTOMER, + "contacted_by": "Administrator", + "date_of_communication": frappe.utils.now(), + "communication_feedback": "Customer asked for a quotation", + } + ).insert() + self.assertTrue(doc.name.startswith("COMM-")) + self.assertEqual(frappe.db.get_value("Communications", doc.name, "customer"), CUSTOMER) + + def test_invalid_communication_type_is_rejected(self): + with self.assertRaises(frappe.ValidationError): + frappe.get_doc( + {"doctype": "Communications", "naming_series": "COMM-", "type_of_communication": "Fax"} + ).insert() + + def test_unknown_user_is_rejected(self): + with self.assertRaises(frappe.LinkValidationError): + frappe.get_doc( + {"doctype": "Communications", "naming_series": "COMM-", "contacted_by": "nobody@example.com"} + ).insert() + + +class TestMarketingDept(IntegrationTestCase): + def test_insert(self): + doc = frappe.get_doc({"doctype": "Marketing Dept", "dept_name": "Digital"}).insert() + self.assertEqual(frappe.db.get_value("Marketing Dept", doc.name, "dept_name"), "Digital") + + +class TestAllertCustom(IntegrationTestCase): + def test_insert(self): + doc = frappe.get_doc({"doctype": "Allert Custom"}).insert() + self.assertTrue(frappe.db.exists("Allert Custom", doc.name)) From 48f7cd22dc603fe2dfc57948fddfbcc2b13f8425 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:03 +0300 Subject: [PATCH 49/74] test: cover the setup, custom field and property setter installers --- csf_tz/tests/test_setup_utils.py | 188 +++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 csf_tz/tests/test_setup_utils.py diff --git a/csf_tz/tests/test_setup_utils.py b/csf_tz/tests/test_setup_utils.py new file mode 100644 index 00000000..7489ac68 --- /dev/null +++ b/csf_tz/tests/test_setup_utils.py @@ -0,0 +1,188 @@ +import json +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz.utils import create_custom_fields as custom_fields_util +from csf_tz.utils import create_property_setter as property_setter_util +from csf_tz.utils import setup + +TZ_COMPANY = "_Test TZ Setup Company" +TZ_ABBR = "_TTZ" + + +def make_tz_company(): + if frappe.db.exists("Company", TZ_COMPANY): + return TZ_COMPANY + frappe.get_doc( + { + "doctype": "Company", + "company_name": TZ_COMPANY, + "abbr": TZ_ABBR, + "default_currency": "TZS", + "country": "Tanzania", + "chart_of_accounts": "Standard", + } + ).insert() + return TZ_COMPANY + + +def count_records(): + return {spec["doctype"]: frappe.db.count(spec["doctype"]) for spec in setup.SETUP_SPECS} + + +class TestSetupExecute(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = make_tz_company() + + def test_execute_skips_without_company_or_non_tzs_company(self): + with patch.object(setup, "get_target_company", return_value=None): + setup.execute() + with patch.object(setup, "get_target_company", return_value="_Test Company"): + setup.execute() + self.assertFalse(frappe.db.exists("Account", "Payroll Liabilities - _TC")) + self.assertFalse(frappe.db.exists("Sales Taxes and Charges Template", "Standard (18%) - _TC")) + + def test_execute_creates_tz_defaults_idempotently(self): + with patch.object(setup, "get_target_company", return_value=self.company): + setup.execute() + counts = count_records() + setup.execute() + self.assertEqual(count_records(), counts) + + self.assertTrue(frappe.db.exists("Account", f"Payroll Liabilities - {TZ_ABBR}")) + self.assertEqual( + frappe.db.get_value("Account", f"Input VAT - {TZ_ABBR}", ["account_type", "parent_account"]), + ("Tax", f"Tax Assets - {TZ_ABBR}"), + ) + self.assertTrue(frappe.db.exists("Leave Type", "Annual Leave")) + self.assertTrue(frappe.db.exists("Salary Component", "NSSF Employee")) + self.assertTrue(frappe.db.exists("Sales Taxes and Charges Template", f"Standard (18%) - {TZ_ABBR}")) + self.assertTrue( + frappe.db.exists("Purchase Taxes and Charges Template", f"Standard (18%) - {TZ_ABBR}") + ) + self.assertTrue(frappe.db.exists("Item Tax Template", f"Standard (18%) Sales Tax - {TZ_ABBR}")) + self.assertEqual( + frappe.db.get_value("Leave Policy", {"title": "Leave Policy For Men"}, "docstatus"), + 1, + ) + self.assertEqual(frappe.db.get_value("Salary Structure", "TZ Standard", "company"), self.company) + + def test_get_target_company(self): + with patch("frappe.db.get_single_value", return_value="_Test Company"): + self.assertEqual(setup.get_target_company(), "_Test Company") + with patch("frappe.db.get_single_value", return_value=None): + self.assertEqual( + setup.get_target_company(), + frappe.get_all("Company", pluck="name", order_by="creation asc", limit=1)[0], + ) + + def test_render_and_resolve_helpers(self): + record = {"a": "{company} - {abbr}", "rows": [{"parent_account": "Nested {abbr}"}], "n": 1} + self.assertEqual( + setup.render_record(record, "Co", "AB"), + {"a": "Co - AB", "rows": [{"parent_account": "Nested AB"}], "n": 1}, + ) + self.assertEqual(setup.strip_company_suffix("Cash - _TC", "_TC"), "Cash") + self.assertEqual(setup.strip_company_suffix("Cash", "_TC"), "Cash") + + self.assertEqual(setup.resolve_account("Cash - _TC", "_Test Company", "_TC"), "Cash - _TC") + self.assertEqual(setup.resolve_account("Cash - XX", "_Test Company", "XX"), "Cash - _TC") + self.assertEqual( + setup.resolve_account("No Such Account - XX", "_Test Company", "XX"), "No Such Account - XX" + ) + self.assertEqual( + setup.resolve_cost_center("_Test Cost Center - XX", "_Test Company", "XX"), + "_Test Cost Center - _TC", + ) + self.assertEqual(setup.resolve_cost_center("Nope - XX", "_Test Company", "XX"), "Nope - XX") + + resolved = setup.resolve_links( + { + "account": "Cash - XX", + "taxes": [{"account_head": "Cash - XX", "cost_center": "_Test Cost Center - XX"}], + }, + "_Test Company", + "XX", + ) + self.assertEqual(resolved["account"], "Cash - _TC") + self.assertEqual( + resolved["taxes"][0], {"account_head": "Cash - _TC", "cost_center": "_Test Cost Center - _TC"} + ) + + def test_existing_name_and_submit_helpers(self): + self.assertIsNone(setup.get_existing_name("Account", {"account_name": None}, ("account_name",))) + self.assertEqual( + setup.get_existing_name( + "Account", {"account_name": "Cash", "company": "_Test Company"}, ("account_name", "company") + ), + "Cash - _TC", + ) + setup.submit_if_needed({"doctype": "Account"}, "Cash - _TC") + setup.submit_if_needed({"doctype": "Leave Policy", "submit_after_insert": True}, "No Such Policy") + self.assertEqual(len(setup.load_records("leave_types.json")), 6) + with self.assertRaises(FileNotFoundError): + setup.load_records("missing.json") + + +class TestCustomFieldAndPropertySetterUtils(IntegrationTestCase): + def test_create_custom_fields_execute_is_idempotent(self): + custom_fields_util.execute() + count = frappe.db.count("Custom Field") + custom_fields_util.execute() + self.assertEqual(frappe.db.count("Custom Field"), count) + self.assertTrue(frappe.db.exists("Custom Field", "Payroll Entry-cheque_number")) + self.assertTrue(frappe.db.exists("Custom Field", "Employee-kcb_beneficiary_clearing_code")) + + def test_create_fields_from_json_skips_unknown_doctype(self): + count = frappe.db.count("Custom Field") + custom_fields_util.create_fields_from_json( + [{"dt": "No Such DocType", "fieldname": "x", "fieldtype": "Data", "label": "X"}] + ) + self.assertEqual(frappe.db.count("Custom Field"), count) + self.assertEqual( + custom_fields_util.load_json("16_payroll_entry_cheque.json")[1]["fieldname"], "cheque_number" + ) + + def test_export_custom_fields(self): + exported = custom_fields_util.export_custom_fields( + json.dumps(["Employee-kcb_beneficiary_clearing_code"]) + ) + self.assertIn("'fieldname': 'kcb_beneficiary_clearing_code'", exported) + self.assertNotIn("'creation'", exported) + + def test_create_property_setter_execute_is_idempotent(self): + property_setter_util.execute() + count = frappe.db.count("Property Setter") + property_setter_util.execute() + self.assertEqual(frappe.db.count("Property Setter"), count) + + def test_create_property_setter_from_json(self): + setters = [ + { + "name": "Item-description-bold", + "doc_type": "Item", + "doctype_or_field": "DocField", + "field_name": "description", + "property": "bold", + "property_type": "Check", + "value": "1", + }, + { + "name": "No Such-x", + "doc_type": "No Such DocType", + "doctype_or_field": "DocType", + "property": "track_changes", + "property_type": "Check", + "value": "1", + }, + ] + property_setter_util.create_property_setter_from_json(setters) + self.assertTrue(frappe.db.exists("Property Setter", "Item-description-bold")) + count = frappe.db.count("Property Setter") + property_setter_util.create_property_setter_from_json(setters) + self.assertEqual(frappe.db.count("Property Setter"), count) + self.assertTrue(property_setter_util.load_json("01_init.json")) From f4430976b91bcd69238d908b2f507d8bd5a9ff12 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:09 +0300 Subject: [PATCH 50/74] test: cover the bin balance repair helpers --- csf_tz/tests/test_stock_balance_qty.py | 221 +++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 csf_tz/tests/test_stock_balance_qty.py diff --git a/csf_tz/tests/test_stock_balance_qty.py b/csf_tz/tests/test_stock_balance_qty.py new file mode 100644 index 00000000..d5c9b8a0 --- /dev/null +++ b/csf_tz/tests/test_stock_balance_qty.py @@ -0,0 +1,221 @@ +from unittest.mock import patch + +import frappe +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.utils import add_days, nowtime, today + +from csf_tz.csftz_hooks import items_revaluation +from csf_tz.utils import fix_balance_qty + +COMPANY = "_Test Company" +WAREHOUSE = "_Test Warehouse - _TC" + + +def sle_of(stock_entry): + return frappe.db.get_value( + "Stock Ledger Entry", {"voucher_no": stock_entry.name, "is_cancelled": 0}, "name" + ) + + +def corrupt_balance(test_case, sle_name, wrong_qty=99): + correct_qty = frappe.db.get_value("Stock Ledger Entry", sle_name, "qty_after_transaction") + frappe.db.set_value( + "Stock Ledger Entry", sle_name, "qty_after_transaction", wrong_qty, update_modified=False + ) + test_case.addCleanup( + frappe.db.set_value, + "Stock Ledger Entry", + sle_name, + "qty_after_transaction", + correct_qty, + update_modified=False, + ) + + +class TestIncorrectBalanceDetection(UnitTestCase): + def rows(self, *specs): + return [ + frappe._dict( + voucher_type=voucher_type, actual_qty=actual, qty_after_transaction=after, batch_no=batch + ) + for voucher_type, actual, after, batch in specs + ] + + def test_consistent_rows_return_nothing(self): + rows = self.rows(("Stock Entry", 10, 10, None), ("Stock Entry", -4, 6, None)) + self.assertIsNone(items_revaluation.get_incorrect_data(rows)) + + def test_first_wrong_row_is_returned_with_difference(self): + rows = self.rows( + ("Stock Entry", 10, 10, None), ("Stock Entry", -4, 9, None), ("Stock Entry", 1, 10, None) + ) + row = items_revaluation.get_incorrect_data(rows) + self.assertEqual(row.qty_after_transaction, 9) + self.assertEqual(row.expected_balance_qty, 6) + self.assertEqual(row.differnce, 3) + + def test_stock_reconciliation_resets_balance(self): + rows = self.rows( + ("Stock Entry", 10, 10, None), ("Stock Reconciliation", 0, 5, None), ("Stock Entry", 1, 6, None) + ) + self.assertIsNone(items_revaluation.get_incorrect_data(rows)) + + def test_batch_reconciliation_does_not_reset_balance(self): + rows = self.rows(("Stock Entry", 10, 10, None), ("Stock Reconciliation", 0, 5, "BATCH-1")) + self.assertEqual(items_revaluation.get_incorrect_data(rows).differnce, 5) + + def test_validate_data_collects_one_row_per_item_warehouse(self): + grouped = { + ("A", "W"): self.rows(("Stock Entry", 10, 10, None)), + ("B", "W"): self.rows(("Stock Entry", 10, 12, None)), + } + self.assertEqual(len(items_revaluation.validate_data(grouped)), 1) + + +class TestItemsRevaluation(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name + cls.receipt = make_stock_entry( + item_code=cls.item, qty=10, to_warehouse=WAREHOUSE, rate=10, company=COMPANY + ) + cls.issue = make_stock_entry(item_code=cls.item, qty=4, from_warehouse=WAREHOUSE, company=COMPANY) + + def item_filters(self): + return {"item_code": self.item, "warehouse": WAREHOUSE} + + def test_consistent_ledger_has_no_incorrect_rows(self): + self.assertEqual(items_revaluation.get_data(self.item_filters()), []) + + def test_ledger_entries_are_ordered_by_posting_datetime(self): + entries = items_revaluation.get_stock_ledger_entries(frappe._dict(self.item_filters())) + self.assertEqual([row.voucher_no for row in entries], [self.receipt.name, self.issue.name]) + + def test_wrong_balance_is_reported(self): + corrupt_balance(self, sle_of(self.issue)) + rows = items_revaluation.get_data(self.item_filters()) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].voucher_no, self.issue.name) + self.assertEqual(rows[0].expected_balance_qty, 6) + self.assertEqual(rows[0].differnce, 93) + + def test_company_filter_excludes_other_companies(self): + corrupt_balance(self, sle_of(self.issue)) + self.assertEqual( + items_revaluation.get_data({"company": "_Test Company 1", "item_code": self.item}), [] + ) + + def test_process_creates_repost_and_fixes_balance(self): + corrupt_balance(self, sle_of(self.issue)) + with patch.object(frappe.db, "commit"): + items_revaluation.process_incorrect_balance_qty() + repost = frappe.get_last_doc("Repost Item Valuation", filters={"voucher_no": self.issue.name}) + self.assertEqual( + (repost.based_on, repost.docstatus, repost.allow_negative_stock), ("Transaction", 1, 1) + ) + self.assertEqual(repost.voucher_type, "Stock Entry") + self.assertEqual( + frappe.db.get_value("Stock Ledger Entry", sle_of(self.issue), "qty_after_transaction"), 6 + ) + + def test_process_does_nothing_for_consistent_ledger(self): + before = frappe.db.count("Repost Item Valuation") + with patch.object(frappe.db, "commit"): + items_revaluation.process_incorrect_balance_qty() + self.assertEqual(frappe.db.count("Repost Item Valuation"), before) + + +class TestHasCorrectBalanceQty(UnitTestCase): + def previous(self, qty): + return frappe._dict(item_code="A", warehouse="W", qty_after_transaction=qty) + + def sle(self, actual, after, voucher_type="Stock Entry", item_code="A", batch_no=None): + return frappe._dict( + item_code=item_code, + warehouse="W", + voucher_type=voucher_type, + actual_qty=actual, + qty_after_transaction=after, + serial_no=None, + batch_no=batch_no, + ) + + def test_running_balance_matches(self): + sles = [self.sle(5, 15), self.sle(-2, 13)] + self.assertTrue(fix_balance_qty.has_correct_balance_qty(self.previous(10), sles)) + + def test_mismatch_is_detected(self): + sles = [self.sle(5, 15), self.sle(-2, 99)] + self.assertFalse(fix_balance_qty.has_correct_balance_qty(self.previous(10), sles)) + + def test_other_item_rows_are_ignored(self): + sles = [self.sle(5, 999, item_code="B"), self.sle(5, 15)] + self.assertTrue(fix_balance_qty.has_correct_balance_qty(self.previous(10), sles)) + + def test_reconciliation_resets_balance_unless_serial_or_batch(self): + reset = [self.sle(0, 3, voucher_type="Stock Reconciliation"), self.sle(1, 4)] + self.assertTrue(fix_balance_qty.has_correct_balance_qty(self.previous(10), reset)) + batched = [self.sle(0, 3, voucher_type="Stock Reconciliation", batch_no="B1")] + self.assertFalse(fix_balance_qty.has_correct_balance_qty(self.previous(10), batched)) + + +class TestFixBalanceQty(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name + cls.old_receipt = make_stock_entry( + item_code=cls.item, + qty=10, + to_warehouse=WAREHOUSE, + rate=10, + company=COMPANY, + posting_date=add_days(today(), -3), + posting_time=nowtime(), + ) + cls.receipt = make_stock_entry( + item_code=cls.item, qty=5, to_warehouse=WAREHOUSE, rate=10, company=COMPANY + ) + cls.issue = make_stock_entry(item_code=cls.item, qty=2, from_warehouse=WAREHOUSE, company=COMPANY) + + def reposts_for_item(self): + return frappe.get_all( + "Repost Item Valuation", + filters={"item_code": self.item, "warehouse": WAREHOUSE, "based_on": "Item and Warehouse"}, + fields=["name", "posting_date", "docstatus", "allow_negative_stock", "company"], + ) + + def test_consistent_ledger_creates_no_repost(self): + fix_balance_qty.execute() + self.assertEqual(self.reposts_for_item(), []) + + def test_wrong_recent_balance_is_reposted_from_previous_entry(self): + corrupt_balance(self, sle_of(self.issue)) + existing = {repost.name for repost in self.reposts_for_item()} + fix_balance_qty.execute() + reposts = [repost for repost in self.reposts_for_item() if repost.name not in existing] + self.assertEqual(len(reposts), 1) + self.assertEqual(str(reposts[0].posting_date), add_days(today(), -3)) + self.assertEqual( + (reposts[0].docstatus, reposts[0].allow_negative_stock, reposts[0].company), (1, 1, COMPANY) + ) + self.assertEqual( + frappe.db.get_value("Stock Ledger Entry", sle_of(self.issue), "qty_after_transaction"), 13 + ) + + def test_create_repost_item_valuation_entry(self): + fix_balance_qty.create_repost_item_valuation_entry( + { + "item_code": self.item, + "warehouse": WAREHOUSE, + "posting_date": today(), + "posting_time": nowtime(), + "company": COMPANY, + } + ) + reposts = self.reposts_for_item() + self.assertEqual(len(reposts), 1) + self.assertEqual(reposts[0].docstatus, 1) From 5abe1d83bc7a5f4077e938290dd310fc7d3b074d Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:14 +0300 Subject: [PATCH 51/74] test: cover the BOM cost import on stock entries --- csf_tz/tests/test_stock_bom_hook.py | 172 ++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 csf_tz/tests/test_stock_bom_hook.py diff --git a/csf_tz/tests/test_stock_bom_hook.py b/csf_tz/tests/test_stock_bom_hook.py new file mode 100644 index 00000000..21bdd249 --- /dev/null +++ b/csf_tz/tests/test_stock_bom_hook.py @@ -0,0 +1,172 @@ +from unittest.mock import patch + +import frappe +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.material_request.material_request import ( + make_stock_entry as make_entry_from_request, +) +from erpnext.stock.doctype.material_request.test_material_request import make_material_request +from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from frappe.tests import IntegrationTestCase + +from csf_tz.csftz_hooks.stock import ( + import_from_bom, + validate_with_material_request, + validate_with_material_request_override, +) + +COMPANY = "_Test Company" +WAREHOUSE = "_Test Warehouse - _TC" +TARGET_WAREHOUSE = "_Test Warehouse 1 - _TC" +EXPENSE_ACCOUNT = "Expenses Included In Valuation - _TC" + + +def make_bom(finished_item, raw_item): + bom = frappe.new_doc("BOM") + bom.item = finished_item + bom.company = COMPANY + bom.quantity = 1 + bom.append("items", {"item_code": raw_item, "qty": 2, "rate": 50}) + bom.insert() + bom.submit() + return bom + + +def bom_with_costs(bom, rows): + real_get_doc = frappe.get_doc + + def get_doc(*args, **kwargs): + if args[:2] == ("BOM", bom.name): + bom.additional_costs = rows + return bom + return real_get_doc(*args, **kwargs) + + return patch.object(frappe, "get_doc", side_effect=get_doc) + + +class TestImportFromBom(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.raw_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 50}).name + cls.finished_item = make_item(properties={"is_stock_item": 1}).name + cls.bom = make_bom(cls.finished_item, cls.raw_item) + make_stock_entry(item_code=cls.raw_item, qty=50, to_warehouse=WAREHOUSE, rate=50, company=COMPANY) + + def make_manufacture_entry(self): + entry = frappe.new_doc("Stock Entry") + entry.company = COMPANY + entry.stock_entry_type = "Manufacture" + entry.purpose = "Manufacture" + entry.from_bom = 1 + entry.bom_no = self.bom.name + entry.fg_completed_qty = 1 + entry.from_warehouse = WAREHOUSE + entry.to_warehouse = TARGET_WAREHOUSE + entry.get_items() + return entry + + def test_bom_without_cost_table_leaves_entry_untouched(self): + entry = self.make_manufacture_entry() + entry.save() + self.assertEqual(entry.additional_costs, []) + self.assertEqual(entry.total_additional_costs, 0) + + def test_bom_costs_are_copied_and_distributed(self): + rows = [ + frappe._dict(expense_account=EXPENSE_ACCOUNT, cost_per_unit=30, cost_type="Labour"), + frappe._dict(expense_account=EXPENSE_ACCOUNT, cost_per_unit=20, cost_type="Power"), + ] + entry = self.make_manufacture_entry() + with bom_with_costs(self.bom, rows): + entry.save() + self.assertEqual( + [ + (row.expense_account, row.amount, row.base_amount, row.description) + for row in entry.additional_costs + ], + [(EXPENSE_ACCOUNT, 30, 30, "Labour"), (EXPENSE_ACCOUNT, 20, 20, "Power")], + ) + entry.submit() + self.assertEqual(entry.total_additional_costs, 50) + finished_row = next(row for row in entry.items if row.is_finished_item) + self.assertEqual(finished_row.additional_cost, 50) + + def test_saving_again_does_not_duplicate_costs(self): + rows = [frappe._dict(expense_account=EXPENSE_ACCOUNT, cost_per_unit=30, cost_type="Labour")] + entry = self.make_manufacture_entry() + with bom_with_costs(self.bom, rows): + entry.save() + entry.remarks = "saved twice" + entry.save() + self.assertEqual(len(entry.additional_costs), 1) + + def test_other_entry_types_do_not_read_the_bom(self): + entry = make_stock_entry( + item_code=self.raw_item, qty=1, to_warehouse=WAREHOUSE, rate=50, company=COMPANY, do_not_save=True + ) + entry.bom_no = self.bom.name + with patch.object(frappe, "get_doc", side_effect=AssertionError("BOM must not be loaded")): + import_from_bom(entry, "before_save") + self.assertEqual(entry.additional_costs, []) + + +class TestValidateWithMaterialRequest(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name + make_stock_entry(item_code=cls.item, qty=20, to_warehouse=WAREHOUSE, rate=10, company=COMPANY) + cls.request = make_material_request( + material_request_type="Material Transfer", + item_code=cls.item, + qty=5, + uom="Nos", + from_warehouse=WAREHOUSE, + warehouse=TARGET_WAREHOUSE, + ) + + def make_transfer_entry(self): + entry = make_entry_from_request(self.request.name) + entry.stock_entry_type = "Material Transfer" + return entry + + def test_matching_item_and_warehouse_pass(self): + entry = self.make_transfer_entry() + self.assertEqual(entry.items[0].material_request, self.request.name) + validate_with_material_request(entry) + + def test_item_mismatch_throws(self): + entry = self.make_transfer_entry() + entry.items[0].item_code = "_Test Item" + with self.assertRaises(frappe.MappingMismatchError): + validate_with_material_request(entry) + + def test_target_warehouse_mismatch_throws(self): + entry = self.make_transfer_entry() + entry.items[0].t_warehouse = WAREHOUSE + with self.assertRaises(frappe.MappingMismatchError): + validate_with_material_request(entry) + + def test_material_issue_compares_source_warehouse(self): + entry = self.make_transfer_entry() + entry.purpose = "Material Issue" + entry.items[0].s_warehouse = TARGET_WAREHOUSE + validate_with_material_request(entry) + entry.items[0].s_warehouse = WAREHOUSE + with self.assertRaises(frappe.MappingMismatchError): + validate_with_material_request(entry) + + def test_company_bypass_skips_validation(self): + frappe.db.set_value("Company", COMPANY, "bypass_material_request_validation", 1) + self.addCleanup(frappe.db.set_value, "Company", COMPANY, "bypass_material_request_validation", 0) + entry = self.make_transfer_entry() + entry.items[0].item_code = "_Test Item" + validate_with_material_request(entry) + + def test_override_replaces_erpnext_method(self): + original = StockEntry.validate_with_material_request + self.addCleanup(setattr, StockEntry, "validate_with_material_request", original) + validate_with_material_request_override(None, "before_validate") + self.assertIs(StockEntry.validate_with_material_request, validate_with_material_request) From 4646251b9198b5a95ff173b80c70720ef10ba674 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:20 +0300 Subject: [PATCH 52/74] test: cover the purchase and stock management doctypes --- csf_tz/tests/test_stock_doctypes.py | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 csf_tz/tests/test_stock_doctypes.py diff --git a/csf_tz/tests/test_stock_doctypes.py b/csf_tz/tests/test_stock_doctypes.py new file mode 100644 index 00000000..099f6fce --- /dev/null +++ b/csf_tz/tests/test_stock_doctypes.py @@ -0,0 +1,115 @@ +from unittest.mock import patch + +import frappe +from erpnext.stock.utils import get_or_make_bin +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +ITEM = "_Test Item" +WAREHOUSE = "_Test Warehouse - _TC" + + +class TestOrderTrack(IntegrationTestCase): + def make_order_track(self, **values): + return frappe.get_doc( + { + "doctype": "Order Track", + "supplier": "_Test Supplier", + "supplier_type": "International Supplier", + "expected_arrival_date": add_days(today(), 30), + "bl_number": "BL-001", + **values, + } + ).insert() + + def test_name_follows_series(self): + doc = self.make_order_track() + self.assertRegex(doc.name, r"^ORDERTRACK\d{4}$") + self.assertEqual(frappe.db.get_value("Order Track", doc.name, "bl_number"), "BL-001") + + def test_submit_cancel_and_amend(self): + doc = self.make_order_track() + doc.submit() + doc.cancel() + amended = frappe.copy_doc(doc) + amended.docstatus = 0 + amended.amended_from = doc.name + amended.insert() + self.assertEqual(amended.name, f"{doc.name}-1") + + def test_invalid_supplier_type_is_rejected(self): + with self.assertRaises(frappe.ValidationError): + self.make_order_track(supplier_type="Unknown Supplier") + + def test_unknown_supplier_is_rejected(self): + with self.assertRaises(frappe.LinkValidationError): + self.make_order_track(supplier="No Such Supplier") + + def test_container_table_is_a_child_doctype(self): + meta = frappe.get_meta("Order Tracking Container") + self.assertTrue(meta.istable) + self.assertEqual([field.fieldname for field in meta.fields], ["co_name", "co_number"]) + + +class TestPurchaseAndStockManagementTest(IntegrationTestCase): + def test_insert(self): + doc = frappe.get_doc({"doctype": "Purchase And Stock Management Test", "pstest": "probe"}).insert() + self.assertEqual( + frappe.db.get_value("Purchase And Stock Management Test", doc.name, "pstest"), "probe" + ) + + +class TestItemNumber(IntegrationTestCase): + def test_single_value_is_saved(self): + doc = frappe.get_single("Item Number") + doc.id = 42 + doc.save() + self.assertEqual(frappe.db.get_single_value("Item Number", "id"), 42) + + +def patch_bin_loading(bin_doc=None): + """Serve `bin_doc` for Bin lookups and fail on any other Bin load; everything else stays real.""" + real_get_doc = frappe.get_doc + + def get_doc(*args, **kwargs): + if args and args[0] == "Bin": + if bin_doc and args[1] == bin_doc.name: + return bin_doc + raise AssertionError(f"unexpected Bin load: {args}") + return real_get_doc(*args, **kwargs) + + return patch.object(frappe, "get_doc", side_effect=get_doc) + + +class TestBinSetup(IntegrationTestCase): + def make_setup(self, **row): + setup = frappe.get_single("Bin Setup") + setup.bin_table = [] + setup.append("bin_table", {"item_code": ITEM, "warehouse": WAREHOUSE, **row}) + return setup + + def test_new_label_is_written_to_the_bin(self): + bin_doc = frappe.get_doc("Bin", get_or_make_bin(ITEM, WAREHOUSE)) + setup = self.make_setup(new_label="A-01") + with patch_bin_loading(bin_doc), patch.object(bin_doc, "save") as save: + setup.save() + self.assertEqual(bin_doc.bin_label, "A-01") + save.assert_called_once() + self.assertEqual(setup.bin_table, []) + self.assertEqual(frappe.db.count("Bin List", {"parent": "Bin Setup"}), 0) + + def test_rows_without_new_label_are_ignored(self): + setup = self.make_setup(new_label="") + with patch_bin_loading(): + setup.save() + self.assertEqual(setup.bin_table, []) + + def test_missing_bin_is_skipped(self): + item = frappe.get_doc( + {"doctype": "Item", "item_code": "_Test Bin Setup Item", "item_group": "Products"} + ) + item.insert(ignore_if_duplicate=True) + setup = self.make_setup(item_code=item.name, new_label="B-02") + with patch_bin_loading(): + setup.save() + self.assertEqual(setup.bin_table, []) From 4686bf1153a5912b240d3d933c28337435510db2 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:24 +0300 Subject: [PATCH 53/74] test: cover the item reposting job --- csf_tz/tests/test_stock_item_reposting.py | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 csf_tz/tests/test_stock_item_reposting.py diff --git a/csf_tz/tests/test_stock_item_reposting.py b/csf_tz/tests/test_stock_item_reposting.py new file mode 100644 index 00000000..2192f62e --- /dev/null +++ b/csf_tz/tests/test_stock_item_reposting.py @@ -0,0 +1,74 @@ +from datetime import datetime +from unittest.mock import patch + +import frappe +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from csf_tz.csftz_hooks import item_reposting + +MODULE = "csf_tz.csftz_hooks.item_reposting" + + +class TestItemReposting(IntegrationTestCase): + def set_start_date(self, value): + frappe.db.set_single_value("CSF TZ Settings", "sle_gle_reposting_start_date", value) + + def test_enqueue_requires_start_date(self): + self.set_start_date(None) + with self.assertRaises(frappe.ValidationError): + item_reposting.enqueue_reposting_sle_gle() + + def test_enqueue_starts_long_job(self): + self.set_start_date(add_days(today(), -1)) + with patch(f"{MODULE}.enqueue") as enqueue: + item_reposting.enqueue_reposting_sle_gle() + enqueue.assert_called_once() + self.assertIs(enqueue.call_args.kwargs["method"], item_reposting.execute) + self.assertEqual(enqueue.call_args.kwargs["queue"], "long") + + def test_start_date_is_read_as_date(self): + self.set_start_date("2026-08-20") + self.assertEqual(str(item_reposting.get_reposting_start_date()), "2026-08-20") + + def test_enqueue_is_whitelisted(self): + frappe.is_whitelisted(item_reposting.enqueue_reposting_sle_gle) + + def test_execute_requires_start_date(self): + self.set_start_date(None) + with self.assertRaises(frappe.ValidationError): + item_reposting.execute() + + def test_execute_skips_when_start_date_is_today(self): + self.set_start_date(today()) + with patch(f"{MODULE}.update_entries_after") as update_entries_after: + item_reposting.execute() + update_entries_after.assert_not_called() + + def test_execute_reposts_entries_created_after_start_date(self): + self.set_start_date(add_days(today(), -1)) + entry = make_stock_entry(item_code="_Test Item", qty=1, to_warehouse="_Test Warehouse - _TC", rate=10) + sle = frappe.db.get_value( + "Stock Ledger Entry", {"voucher_no": entry.name}, ["name", "item_code", "warehouse"], as_dict=True + ) + with ( + patch(f"{MODULE}.update_entries_after") as update_entries_after, + patch(f"{MODULE}.update_gl_entries_after") as update_gl_entries_after, + ): + item_reposting.execute() + reposted = [call.args[0] for call in update_entries_after.call_args_list] + mine = next(args for args in reposted if args["sle_id"] == sle.name) + self.assertEqual( + (mine["item_code"], mine["warehouse"], mine["voucher_no"]), + (sle.item_code, sle.warehouse, entry.name), + ) + self.assertTrue( + all(call.kwargs["allow_negative_stock"] for call in update_entries_after.call_args_list) + ) + perpetual_companies = {call.kwargs["company"] for call in update_gl_entries_after.call_args_list} + self.assertNotIn("_Test Company", perpetual_companies) + self.assertEqual(frappe.db.auto_commit_on_many_writes, 0) + + def test_get_creation_time(self): + self.assertIsInstance(item_reposting.get_creation_time(), datetime) From a09cdd26394c24f9a43bbc0b30fda9ddb5cf5bb6 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:30 +0300 Subject: [PATCH 54/74] test: cover automatic material request closing --- csf_tz/tests/test_stock_material_request.py | 87 +++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 csf_tz/tests/test_stock_material_request.py diff --git a/csf_tz/tests/test_stock_material_request.py b/csf_tz/tests/test_stock_material_request.py new file mode 100644 index 00000000..0a5ddb98 --- /dev/null +++ b/csf_tz/tests/test_stock_material_request.py @@ -0,0 +1,87 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, today + +from csf_tz.csftz_hooks.material_request import ( + _auto_close_material_request_batch, + auto_close_material_request, +) + +COMPANY = "_Test Company" +MODULE = "csf_tz.csftz_hooks.material_request" + + +def run_enqueued_jobs_inline(method, **kwargs): + return method(**kwargs["kwargs"]) + + +def make_request(days_ago): + date = add_days(today(), -days_ago) + request = frappe.new_doc("Material Request") + request.update({"material_request_type": "Purchase", "company": COMPANY, "transaction_date": date}) + request.append( + "items", + {"item_code": "_Test Item", "qty": 10, "schedule_date": date, "warehouse": "_Test Warehouse - _TC"}, + ) + request.insert() + return request.submit() + + +class TestAutoCloseMaterialRequest(IntegrationTestCase): + def enable_auto_close(self, days): + frappe.db.set_value( + "Company", + COMPANY, + {"enable_auto_close_material_request": 1, "close_material_request_after": days}, + ) + self.addCleanup( + frappe.db.set_value, + "Company", + COMPANY, + {"enable_auto_close_material_request": 0, "close_material_request_after": 0}, + ) + + def test_old_requests_are_stopped(self): + self.enable_auto_close(7) + old_request = make_request(10) + with patch(f"{MODULE}.enqueue", side_effect=run_enqueued_jobs_inline): + auto_close_material_request() + self.assertEqual(frappe.db.get_value("Material Request", old_request.name, "status"), "Stopped") + + def test_recent_requests_are_left_open(self): + self.enable_auto_close(7) + recent_request = make_request(2) + with patch(f"{MODULE}.enqueue", side_effect=run_enqueued_jobs_inline): + auto_close_material_request() + self.assertEqual(frappe.db.get_value("Material Request", recent_request.name, "status"), "Pending") + + def test_jobs_go_to_the_long_queue(self): + self.enable_auto_close(7) + old_request = make_request(10) + with patch(f"{MODULE}.enqueue") as enqueue: + auto_close_material_request() + enqueue.assert_called_once() + self.assertIs(enqueue.call_args.args[0], _auto_close_material_request_batch) + self.assertEqual(enqueue.call_args.kwargs["queue"], "long") + self.assertIn(old_request.name, enqueue.call_args.kwargs["kwargs"]["material_request_names"]) + + def test_disabled_company_is_skipped(self): + make_request(10) + with patch(f"{MODULE}.enqueue") as enqueue: + auto_close_material_request() + enqueue.assert_not_called() + + def test_batch_stops_requests(self): + request = make_request(0) + _auto_close_material_request_batch([request.name]) + self.assertEqual(frappe.db.get_value("Material Request", request.name, "status"), "Stopped") + + def test_batch_logs_missing_request_and_continues(self): + request = make_request(0) + _auto_close_material_request_batch(["MR-DOES-NOT-EXIST", request.name]) + self.assertEqual(frappe.db.get_value("Material Request", request.name, "status"), "Stopped") + self.assertTrue( + frappe.db.exists("Error Log", {"method": "Auto Close Material Request Error: MR-DOES-NOT-EXIST"}) + ) From f81282dbc75f935b1a14be7e5106c3efeede1368 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:34 +0300 Subject: [PATCH 55/74] test: cover the doctype relation and successor helpers --- csf_tz/tests/test_stock_relation_json.py | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 csf_tz/tests/test_stock_relation_json.py diff --git a/csf_tz/tests/test_stock_relation_json.py b/csf_tz/tests/test_stock_relation_json.py new file mode 100644 index 00000000..e4e2101b --- /dev/null +++ b/csf_tz/tests/test_stock_relation_json.py @@ -0,0 +1,32 @@ +import json + +from frappe.tests import IntegrationTestCase + +from csf_tz.csftz_hooks import get_relation_json, get_successor_json + + +class TestRelationJson(IntegrationTestCase): + def test_relation_json_lists_link_targets_per_doctype(self): + doc_list = json.loads(get_relation_json.get_json()) + sales_invoice = next(row for row in doc_list if row["doctype_name"] == "Sales Invoice") + self.assertEqual(sales_invoice["name"], "erpnext.Accounts.Sales Invoice") + self.assertIn("erpnext.Accounts.Customer", sales_invoice["imports"]) + self.assertIn("erpnext.Accounts.Company", sales_invoice["imports"]) + + def test_successor_json_is_limited_to_ancestor_module(self): + doc_list = json.loads(get_successor_json.get_json("Sales Invoice", "Accounts")) + names = {row["doctype_name"] for row in doc_list} + self.assertIn("Sales Invoice", names) + self.assertIn("Payment Entry", names) + self.assertNotIn("Stock Entry", names) + self.assertTrue(all(row["name"].startswith("erpnext.Accounts.") for row in doc_list)) + + def test_successor_json_default_module_is_accounts(self): + default = json.loads(get_successor_json.get_json("Sales Invoice")) + explicit = json.loads(get_successor_json.get_json("Sales Invoice", "Accounts")) + self.assertEqual(default, explicit) + + def test_successor_json_for_stock_module(self): + names = {row["doctype_name"] for row in json.loads(get_successor_json.get_json("Item", "Stock"))} + self.assertIn("Stock Entry", names) + self.assertNotIn("Sales Invoice", names) From afd6d8fde8763a6b0ccff6fabb7c5d8e1cb06e21 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:40 +0300 Subject: [PATCH 56/74] test: assert every hook, doctype, report and patch loads on v16 --- csf_tz/tests/test_v16_integrity.py | 397 +++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 csf_tz/tests/test_v16_integrity.py diff --git a/csf_tz/tests/test_v16_integrity.py b/csf_tz/tests/test_v16_integrity.py new file mode 100644 index 00000000..e77b2b0c --- /dev/null +++ b/csf_tz/tests/test_v16_integrity.py @@ -0,0 +1,397 @@ +"""Framework-level integrity checks for csf_tz on Frappe v16. + +Every check records per-item outcomes to a JSON file so the results can be +aggregated into a coverage report. Set CSF_TZ_TEST_RESULTS_DIR to choose the +output directory. +""" + +import importlib +import json +import os +import pkgutil +import re +import traceback + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import getdate, today + +import csf_tz +from csf_tz import hooks + +APP_PATH = frappe.get_app_path("csf_tz") +CSF_TZ_MODULES = [m.strip() for m in open(os.path.join(APP_PATH, "modules.txt")) if m.strip()] + + +def results_dir(): + path = os.environ.get("CSF_TZ_TEST_RESULTS_DIR") or frappe.get_site_path("private", "csf_tz_test_results") + os.makedirs(path, exist_ok=True) + return path + + +def record(name, items): + with open(os.path.join(results_dir(), f"integrity_{name}.json"), "w") as f: + json.dump(items, f, indent=1, default=str) + + +MISSING_APP_TABLE = re.compile(r"Table '[^']*\.tab([^']+)' doesn't exist") + + +def outcome(fn): + try: + fn() + return {"status": "pass"} + except (frappe.ValidationError, frappe.MandatoryError, frappe.PermissionError) as e: + return {"status": "validation", "error": str(e)[:300]} + except Exception as e: + if MISSING_APP_TABLE.search(str(e)) or isinstance(e, frappe.DoesNotExistError): + return { + "status": "validation", + "error": f"needs a DocType no installed app provides: {str(e)[:200]}", + } + return { + "status": "fail", + "error": f"{type(e).__name__}: {str(e)[:300]}", + "trace": traceback.format_exc()[-1500:], + } + + +def iter_hook_paths(): + for doctype, events in hooks.doc_events.items(): + for event, handlers in events.items(): + for handler in handlers if isinstance(handlers, list) else [handlers]: + yield f"doc_events.{doctype}.{event}", handler + for schedule, handlers in hooks.scheduler_events.items(): + if isinstance(handlers, dict): + for cron, fns in handlers.items(): + for fn in fns: + yield f"scheduler_events.cron[{cron}]", fn + else: + for fn in handlers: + yield f"scheduler_events.{schedule}", fn + for fn in hooks.after_install: + yield "after_install", fn + for fn in hooks.after_migrate: + yield "after_migrate", fn + for fn in hooks.jinja["methods"]: + yield "jinja.methods", fn + for doctype, cls in hooks.override_doctype_class.items(): + yield f"override_doctype_class.{doctype}", cls + + +def default_report_filters(company): + year_start = str(getdate(today()).replace(month=1, day=1)) + fiscal_year = frappe.db.get_value( + "Fiscal Year", {"year_start_date": ["<=", today()], "year_end_date": [">=", today()]} + ) + return frappe._dict( + company=company, + from_date=year_start, + to_date=today(), + date=today(), + as_on_date=today(), + period_start_date=year_start, + period_end_date=today(), + fiscal_year=fiscal_year, + from_fiscal_year=fiscal_year, + to_fiscal_year=fiscal_year, + year_start_date=year_start, + year_end_date=today(), + range="Monthly", + periodicity="Monthly", + based_on="Item", + docstatus="1", + currency=frappe.get_cached_value("Company", company, "default_currency"), + presentation_currency=frappe.get_cached_value("Company", company, "default_currency"), + ageing_based_on="Posting Date", + range1=30, + range2=60, + range3=90, + range4=120, + report_date=today(), + month=getdate(today()).strftime("%b"), + year=getdate(today()).year, + valuation_field_type="Currency", + include_uom=None, + item_code=None, + warehouse=None, + ) + + +def run_with_missing_filter_keys(run, name, filters, attempts=6): + """Query reports raise KeyError for absent %(key)s params; add them as None and retry.""" + filters = frappe._dict(filters) + for _ in range(attempts): + try: + return run(name, filters=filters, ignore_prepared_report=True) + except KeyError as e: + key = e.args[0] + key = key.decode() if isinstance(key, bytes) else key + if not isinstance(key, str) or key in filters: + raise + filters[key] = None + return run(name, filters=filters, ignore_prepared_report=True) + + +class TestV16Integrity(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = ( + frappe.db.get_single_value("Global Defaults", "default_company") + or frappe.get_all("Company", pluck="name", limit=1)[0] + ) + + def assert_all_pass(self, name, items): + record(name, items) + failed = {k: v for k, v in items.items() if v["status"] == "fail"} + self.assertFalse( + failed, + f"{len(failed)}/{len(items)} {name} checks failed:\n" + + json.dumps(failed, indent=1, default=str)[:6000], + ) + + def test_hook_paths_resolve(self): + items = {} + for source, path in iter_hook_paths(): + items[f"{source} -> {path}"] = outcome(lambda p=path: frappe.get_attr(p)) + self.assert_all_pass("hooks", items) + + def test_hooks_merged_into_site(self): + merged = frappe.get_hooks("doc_events") + for doctype, events in hooks.doc_events.items(): + for event, handlers in events.items(): + for handler in handlers if isinstance(handlers, list) else [handlers]: + self.assertIn(handler, merged[doctype][event], f"{doctype}.{event} missing {handler}") + self.assertIn("csf_tz.bundle.js", frappe.get_hooks("app_include_js")) + for doctype, cls in hooks.override_doctype_class.items(): + self.assertIn(cls, frappe.get_hooks("override_doctype_class")[doctype]) + + def test_all_python_modules_import(self): + items = {} + for module in pkgutil.walk_packages(csf_tz.__path__, "csf_tz."): + if ".tests" in module.name or module.name.rsplit(".", 1)[-1].startswith("test_"): + continue + items[module.name] = outcome(lambda m=module.name: importlib.import_module(m)) + self.assert_all_pass("module_imports", items) + + def test_doctype_js_files_exist(self): + items = {} + for doctype, paths in {**hooks.doctype_js, **hooks.doctype_list_js}.items(): + for path in paths if isinstance(paths, list) else [paths]: + full = os.path.join(APP_PATH, path) + items[f"{doctype}: {path}"] = { + "status": "pass" if os.path.exists(full) else "fail", + "error": full, + } + self.assert_all_pass("doctype_js", items) + + def test_bundles_are_built(self): + from frappe.utils.jinja_globals import bundled_asset + + for bundle in ["csf_tz.bundle.js", "jobcards.bundle.js"]: + asset = bundled_asset(bundle) + self.assertIn("/dist/", asset, f"{bundle} not resolved to a built asset: {asset}") + + def test_doctypes(self): + items = {} + on_disk = { + json.load(open(os.path.join(root, f)))["name"] + for root, _dirs, files in os.walk(APP_PATH) + for f in files + if f.endswith(".json") + and os.sep + "doctype" + os.sep in root + and f[:-5] == os.path.basename(root) + } + in_db = set(frappe.get_all("DocType", filters={"module": ["in", CSF_TZ_MODULES]}, pluck="name")) + for name in sorted(on_disk - in_db): + items[f"{name} (on disk)"] = { + "status": "fail", + "error": "DocType JSON exists but not synced into site", + } + for name in sorted(in_db): + + def check(name=name): + meta = frappe.get_meta(name) + doc = frappe.new_doc(name) + assert doc.doctype == name + if not meta.is_virtual and not meta.issingle: + assert frappe.db.table_exists(name), f"table missing for {name}" + if not meta.istable and not meta.issingle: + frappe.get_all(name, limit=1) + if meta.issingle: + frappe.get_single(name) + + items[name] = outcome(check) + self.assert_all_pass("doctypes", items) + + def test_doctype_controllers_are_document_subclasses(self): + from frappe.model.document import Document + + items = {} + for name in frappe.get_all("DocType", filters={"module": ["in", CSF_TZ_MODULES]}, pluck="name"): + items[name] = outcome( + lambda n=name: self.assertTrue(issubclass(frappe.get_doc({"doctype": n}).__class__, Document)) + ) + self.assert_all_pass("controllers", items) + + def test_reports_execute(self): + from frappe.desk.query_report import run + + items = {} + filters = default_report_filters(self.company) + reports = frappe.get_all( + "Report", + filters={"module": ["in", CSF_TZ_MODULES], "disabled": 0}, + fields=["name", "report_type"], + ) + for report in reports: + + def check(name=report.name): + result = run_with_missing_filter_keys(run, name, filters) + assert "columns" in result or "result" in result + + items[f"{report.name} [{report.report_type}]"] = outcome(check) + self.assert_all_pass("reports", items) + + def test_pages_load(self): + from frappe.desk.desk_page import get + + items = {} + for page in frappe.get_all("Page", filters={"module": ["in", CSF_TZ_MODULES]}, pluck="name"): + items[page] = outcome(lambda p=page: get(p)) + self.assert_all_pass("pages", items) + + def test_workspace_links_target_existing_records(self): + items = {} + for workspace in frappe.get_all( + "Workspace", filters={"module": ["in", CSF_TZ_MODULES]}, pluck="name" + ): + for link in frappe.get_doc("Workspace", workspace).links: + if link.type != "Link" or not link.link_to: + continue + exists = frappe.db.exists(link.link_type, link.link_to) + items[f"{workspace}: {link.link_type} {link.link_to}"] = { + "status": "pass" if exists else "fail", + "error": "" if exists else "link target does not exist", + } + self.assert_all_pass("workspace_links", items) + + def test_patches_are_importable(self): + items = {} + for line in open(os.path.join(APP_PATH, "patches.txt")): + line = line.strip() + if not line or line.startswith("["): + continue + if line.startswith("execute:"): + items[line] = outcome(lambda l=line: compile(l[len("execute:") :], "", "exec")) + else: + items[line] = outcome(lambda l=line: importlib.import_module(l.split()[0]).execute) + self.assert_all_pass("patches", items) + + def test_custom_fields_installed(self): + items = {} + folder = os.path.join(APP_PATH, "patches", "custom_fields", "custom_fields_json") + for file in sorted(os.listdir(folder)): + for field in json.load(open(os.path.join(folder, file))): + if not frappe.db.exists("DocType", field["dt"]): + items[f"{file}: {field['dt']}.{field['fieldname']}"] = { + "status": "validation", + "error": "DocType not installed", + } + continue + exists = frappe.db.exists( + "Custom Field", {"dt": field["dt"], "fieldname": field["fieldname"]} + ) + items[f"{file}: {field['dt']}.{field['fieldname']}"] = { + "status": "pass" if exists else "fail", + "error": "" if exists else "missing", + } + self.assert_all_pass("custom_fields", items) + + def test_property_setters_installed(self): + items = {} + folder = os.path.join(APP_PATH, "patches", "property_setter", "property_setter_json") + for file in sorted(os.listdir(folder)): + for setter in json.load(open(os.path.join(folder, file))): + key = f"{file}: {setter['doc_type']}.{setter.get('field_name')}.{setter['property']}" + if not frappe.db.exists("DocType", setter["doc_type"]): + items[key] = {"status": "validation", "error": "DocType not installed"} + continue + filters = {"doc_type": setter["doc_type"], "property": setter["property"]} + if setter.get("field_name"): + filters["field_name"] = setter["field_name"] + exists = frappe.db.exists("Property Setter", filters) + items[key] = {"status": "pass" if exists else "fail", "error": "" if exists else "missing"} + self.assert_all_pass("property_setters", items) + + def test_override_doctype_classes_are_active(self): + for doctype, path in hooks.override_doctype_class.items(): + cls = frappe.get_attr(path) + self.assertIsInstance(frappe.new_doc(doctype), cls, f"{doctype} does not use {path}") + + def test_monkey_patch_is_loaded(self): + from frappe.database.database import Database + + csf_tz.load_monkey_patches() + self.assertEqual( + Database.check_transaction_status.__module__, "csf_tz.monkey_patches.db_transaction_writes" + ) + + def test_whitelisted_methods_are_registered(self): + items = {} + inventory = os.path.join(APP_PATH, "tests", "whitelisted_methods.json") + if not os.path.exists(inventory): + self.skipTest("no whitelisted_methods.json inventory") + for entry in json.load(open(inventory)): + if entry.get("in_class"): + continue + + def check(path=entry["path"]): + fn = frappe.get_attr(path) + frappe.is_whitelisted(fn) + + items[entry["path"]] = outcome(check) + self.assert_all_pass("whitelisted", items) + + def test_jinja_qrcode_method(self): + from csf_tz.custom_api import generate_qrcode + + self.assertTrue( + generate_qrcode("hello").startswith("data:image/png;base64,") + or len(generate_qrcode("hello")) > 20 + ) + self.assertIn("csf_tz.custom_api.generate_qrcode", frappe.get_hooks("jinja")["methods"]) + + def test_config_modules(self): + items = {} + for name in [ + "desktop", + "csf_tz", + "accounts", + "purchase_and_stock_management", + "stock", + "sales_and_marketing", + ]: + items[name] = outcome(lambda n=name: importlib.import_module(f"csf_tz.config.{n}").get_data()) + self.assert_all_pass("config", items) + + def test_setup_data_files(self): + from csf_tz.utils.setup import SETUP_SPECS, load_records + + items = {} + for spec in SETUP_SPECS: + items[spec["file"]] = outcome( + lambda s=spec: (load_records(s["file"]), frappe.get_meta(s["doctype"])) + ) + self.assert_all_pass("setup_data", items) + + def test_dashboard_chart_source(self): + from csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline import ( + multi_account_balance_timeline as src, + ) + + chart = frappe.db.exists("Dashboard Chart", "Multi Bank Balance") + self.assertTrue(chart, "Dashboard Chart 'Multi Bank Balance' not installed") + result = src.get(chart_name="Multi Bank Balance", filters=json.dumps({"company": self.company})) + self.assertIn("datasets", result) From f9ac663cf005709c6d3c11472cacae1f35edd817 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:45 +0300 Subject: [PATCH 57/74] test: cover the vehicle authority notifications with mocked http --- csf_tz/tests/test_vehicle_authority.py | 175 +++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 csf_tz/tests/test_vehicle_authority.py diff --git a/csf_tz/tests/test_vehicle_authority.py b/csf_tz/tests/test_vehicle_authority.py new file mode 100644 index 00000000..3795f56a --- /dev/null +++ b/csf_tz/tests/test_vehicle_authority.py @@ -0,0 +1,175 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz import vehicle_authority +from csf_tz.vehicle_authority import ( + get_authority_notification_recipients, + get_authority_notification_roles, + get_unique_vehicle_plates, + get_vehicle_docname_by_plate, + get_vehicle_like_doctypes, + get_vehicle_like_records, + get_vehicle_plate, + has_vehicle_plate_field, + is_authority_notification_enabled, + is_authority_notification_event_enabled, + run_daily_authority_notifications, + send_authority_notification, +) + +AUTHORITY_ROLE = "_Test Authority Notification Role" +AUTHORITY_USER = "test@example.com" + + +def make_vehicle(plate): + if frappe.db.exists("Vehicle", plate): + return frappe.get_doc("Vehicle", plate) + return frappe.get_doc( + { + "doctype": "Vehicle", + "license_plate": plate, + "make": "Toyota", + "model": "Hilux", + "last_odometer": 100, + "fuel_type": "Diesel", + "uom": "Nos", + } + ).insert() + + +def make_authority_role(): + if not frappe.db.exists("Role", AUTHORITY_ROLE): + frappe.get_doc({"doctype": "Role", "role_name": AUTHORITY_ROLE}).insert() + frappe.get_doc("User", AUTHORITY_USER).add_roles(AUTHORITY_ROLE) + + +def configure_authority_notifications(reference_types=(), **flags): + """Enable notifications for the given reference types and route them to AUTHORITY_ROLE.""" + make_authority_role() + settings = frappe.get_single("CSF TZ Settings") + settings.authority_notification_roles = [] + for reference_type in reference_types: + settings.append( + "authority_notification_roles", {"reference_type": reference_type, "role": AUTHORITY_ROLE} + ) + for fieldname in vehicle_authority.AUTHORITY_ENABLE_FIELD_MAP.values(): + settings.set(fieldname, 0) + for fieldname in vehicle_authority.AUTHORITY_EVENT_ENABLE_FIELD_MAP.values(): + settings.set(fieldname, 0) + for reference_type in reference_types: + settings.set(vehicle_authority.AUTHORITY_ENABLE_FIELD_MAP[reference_type], 1) + for fieldname, value in flags.items(): + settings.set(fieldname, value) + settings.save(ignore_permissions=True) + return settings + + +class TestVehicleAuthority(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.vehicle = make_vehicle("T111AAA") + + def test_get_vehicle_plate_from_document_and_values(self): + self.assertEqual(get_vehicle_plate(self.vehicle), "T111AAA") + meta = frappe.get_meta("Vehicle") + self.assertEqual(get_vehicle_plate(frappe._dict(license_plate=" T222BBB "), meta=meta), "T222BBB") + self.assertIsNone(get_vehicle_plate(frappe._dict(license_plate=""), meta=meta)) + + def test_has_vehicle_plate_field(self): + self.assertTrue(has_vehicle_plate_field(frappe.get_meta("Vehicle"))) + self.assertFalse(has_vehicle_plate_field(frappe.get_meta("Item"))) + + def test_vehicle_like_doctypes_and_records(self): + doctypes = [doctype for doctype, _meta in get_vehicle_like_doctypes()] + self.assertIn("Vehicle", doctypes) + + records = [record for record in get_vehicle_like_records() if record.doctype == "Vehicle"] + self.assertIn( + ("Vehicle", "T111AAA", "T111AAA"), [(r.doctype, r.name, r.plate_number) for r in records] + ) + + def test_get_vehicle_docname_by_plate(self): + self.assertEqual(get_vehicle_docname_by_plate("T111AAA"), self.vehicle.name) + self.assertIsNone(get_vehicle_docname_by_plate("T999ZZZ")) + self.assertIsNone(get_vehicle_docname_by_plate(None)) + + def test_get_unique_vehicle_plates(self): + make_vehicle("t 333 ccc") + plates = get_unique_vehicle_plates( + normalize_number_plate=lambda plate: plate.replace(" ", "").upper(), + is_valid_number_plate=lambda plate: plate != "T111AAA", + ) + self.assertIn("T333CCC", plates) + self.assertNotIn("T111AAA", plates) + self.assertIn("T111AAA", get_unique_vehicle_plates()) + + def test_notification_roles_and_recipients(self): + configure_authority_notifications(["Vehicle Fine"]) + self.assertEqual(get_authority_notification_roles("Vehicle Fine"), [AUTHORITY_ROLE]) + self.assertEqual(get_authority_notification_roles("LATRA License"), []) + self.assertEqual(get_authority_notification_roles("Unknown"), []) + self.assertEqual(get_authority_notification_recipients("Vehicle Fine"), [AUTHORITY_USER]) + self.assertEqual(get_authority_notification_recipients("TIRA"), []) + + def test_disabled_users_are_not_recipients(self): + configure_authority_notifications(["TIRA"]) + frappe.db.set_value("User", AUTHORITY_USER, "enabled", 0) + try: + self.assertEqual(get_authority_notification_recipients("TIRA"), []) + finally: + frappe.db.set_value("User", AUTHORITY_USER, "enabled", 1) + + def test_enabled_flags(self): + configure_authority_notifications(["LATRA Offence"], latra_offence_notify_on_new=1) + self.assertTrue(is_authority_notification_enabled("LATRA Offence")) + self.assertFalse(is_authority_notification_enabled("TIRA")) + self.assertFalse(is_authority_notification_enabled("Unknown")) + self.assertTrue(is_authority_notification_event_enabled("LATRA Offence", "new")) + self.assertFalse(is_authority_notification_event_enabled("LATRA Offence", "status_change")) + self.assertTrue(is_authority_notification_event_enabled("LATRA Offence", "unknown_event")) + + def test_send_authority_notification(self): + configure_authority_notifications([]) + self.assertEqual(send_authority_notification("TIRA", "s", "m"), {"sent": False, "reason": "disabled"}) + + settings = configure_authority_notifications(["TIRA"]) + settings.authority_notification_roles = [] + settings.save(ignore_permissions=True) + self.assertEqual( + send_authority_notification("TIRA", "s", "m"), {"sent": False, "reason": "no_recipients"} + ) + + configure_authority_notifications(["TIRA"]) + with patch("frappe.sendmail") as sendmail: + result = send_authority_notification("TIRA", "Subject", "Message") + self.assertTrue(result["sent"]) + self.assertEqual(result["recipients"], [AUTHORITY_USER]) + sendmail.assert_called_once_with( + recipients=[AUTHORITY_USER], subject="Subject", message="Message", now=True + ) + + def test_run_daily_authority_notifications_calls_every_notifier(self): + targets = [ + "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.notify_latra_license_expiry", + "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.send_pending_latra_offence_notifications", + "csf_tz.csf_tz.doctype.tz_insurance_cover_note.tz_insurance_cover_note.notify_tira_covernote_expiry", + "csf_tz.csf_tz.doctype.vehicle_fine_record.vehicle_fine_record.send_pending_vehicle_fine_notifications", + ] + patchers = [patch(target) for target in targets] + mocks = [patcher.start() for patcher in patchers] + try: + run_daily_authority_notifications() + finally: + for patcher in patchers: + patcher.stop() + for mock in mocks: + mock.assert_called_once_with() + + def test_run_daily_authority_notifications_end_to_end_with_nothing_enabled(self): + configure_authority_notifications([]) + with patch.object(frappe.db, "commit"), patch("frappe.sendmail") as sendmail: + run_daily_authority_notifications() + sendmail.assert_not_called() From 81d69cddcfc31b048da820613ff27fb6446b1028 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:59:51 +0300 Subject: [PATCH 58/74] test: cover vehicle fine lookup and notification --- csf_tz/tests/test_vehicle_fine_record.py | 212 +++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 csf_tz/tests/test_vehicle_fine_record.py diff --git a/csf_tz/tests/test_vehicle_fine_record.py b/csf_tz/tests/test_vehicle_fine_record.py new file mode 100644 index 00000000..6beb8552 --- /dev/null +++ b/csf_tz/tests/test_vehicle_fine_record.py @@ -0,0 +1,212 @@ +import base64 +import hashlib +import json +from unittest.mock import MagicMock, patch + +import frappe +import requests +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from frappe.tests import IntegrationTestCase + +from csf_tz.csf_tz.doctype.vehicle_fine_record import vehicle_fine_record as module +from csf_tz.csf_tz.doctype.vehicle_fine_record.vehicle_fine_record import ( + TPF_SECRET, + check_fine_all_vehicles, + decode_tpf_response, + get_fine, + is_valid_number_plate, + normalize_number_plate, + send_pending_vehicle_fine_notifications, + sync_vehicle_fines, +) +from csf_tz.tests.test_vehicle_authority import ( + AUTHORITY_USER, + configure_authority_notifications, + make_vehicle, +) + +PLATE = "T444DDD" + + +def encrypt_tpf_payload(data): + key = TPF_SECRET[:32].ljust(32, "\0").encode() + iv = hashlib.sha256(TPF_SECRET.encode()).hexdigest()[:16].encode() + padder = padding.PKCS7(128).padder() + padded = padder.update(json.dumps(data).encode()) + padder.finalize() + encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor() + return base64.b64encode(encryptor.update(padded) + encryptor.finalize()).decode() + + +def tpf_response(pending_transactions, status_code=200): + response = MagicMock(status_code=status_code, text="ok") + response.json.return_value = {"pending_transactions": pending_transactions} + return response + + +def fine(reference, status="PENDING", charge=30000, penalty=0): + return { + "reference": reference, + "status": status, + "charge": charge, + "penalty": penalty, + "offence": "Speeding", + "issued_date": "2026-01-15", + "licence": "LIC-1", + "location": "Dar es Salaam", + "officer": "Officer One", + } + + +class TestVehicleFinePlates(IntegrationTestCase): + def test_normalize_number_plate(self): + self.assertEqual(normalize_number_plate(" t-123 abc "), "T123ABC") + self.assertEqual(normalize_number_plate("T123ABCXYZ"), "T123ABC") + self.assertIsNone(normalize_number_plate("")) + self.assertIsNone(normalize_number_plate("---")) + + def test_is_valid_number_plate(self): + self.assertTrue(is_valid_number_plate("T123ABC")) + self.assertTrue(is_valid_number_plate("TZ999AB")) + self.assertFalse(is_valid_number_plate("T12ABC")) + self.assertFalse(is_valid_number_plate("1234567")) + self.assertFalse(is_valid_number_plate(None)) + + def test_decode_tpf_response_passthrough_and_decrypt(self): + plain = {"pending_transactions": [fine("REF-X")]} + self.assertEqual(decode_tpf_response(plain), plain) + + payload = encrypt_tpf_payload(plain) + self.assertEqual(decode_tpf_response({"payload": payload}), plain) + + double_encoded = base64.b64encode(payload.encode()).decode() + self.assertEqual(decode_tpf_response({"payload": double_encoded}), plain) + + +class TestVehicleFineSync(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.vehicle = make_vehicle(PLATE) + cls.commit_patch = patch.object(frappe.db, "commit") + cls.commit_patch.start() + cls.addClassCleanup(cls.commit_patch.stop) + + def test_invalid_plates_are_rejected_without_calling_tpf(self): + with patch("requests.post") as post: + self.assertEqual(sync_vehicle_fines("")["status"], "invalid") + self.assertEqual(sync_vehicle_fines("T12")["status"], "invalid") + post.assert_not_called() + + def test_sync_creates_marks_paid_and_notifies(self): + configure_authority_notifications( + ["Vehicle Fine"], vehicle_fine_notify_on_new=1, vehicle_fine_notify_on_status_change=1 + ) + with ( + patch("requests.post", return_value=tpf_response([fine("REF-A"), {"no": "reference"}])), + patch("frappe.sendmail") as sendmail, + ): + result = sync_vehicle_fines("t 444 ddd") + self.assertEqual(result["status"], "success") + self.assertEqual(result["fine_list"], ["REF-A"]) + record = frappe.get_doc("Vehicle Fine Record", "REF-A") + self.assertEqual(record.vehicle, PLATE) + self.assertEqual(record.vehicle_doc, self.vehicle.name) + self.assertEqual(record.status, "PENDING") + self.assertEqual(record.total, 30000) + self.assertIsNotNone(record.authority_notified_on_new) + self.assertEqual(record.authority_last_notified_status, "PENDING") + self.assertEqual(sendmail.call_count, 1) + + with ( + patch("requests.post", return_value=tpf_response([fine("REF-A"), fine("REF-B")])), + patch("frappe.sendmail") as sendmail, + ): + self.assertEqual(get_fine(PLATE), ["REF-A", "REF-B"]) + self.assertTrue(frappe.db.exists("Vehicle Fine Record", "REF-B")) + self.assertEqual(sendmail.call_count, 1) + + with ( + patch("requests.post", return_value=tpf_response([fine("REF-B")])), + patch("frappe.sendmail") as sendmail, + ): + sync_vehicle_fines(PLATE) + self.assertEqual(frappe.db.get_value("Vehicle Fine Record", "REF-A", "status"), "PAID") + self.assertEqual( + frappe.db.get_value("Vehicle Fine Record", "REF-A", "authority_last_notified_status"), "PAID" + ) + self.assertEqual(frappe.db.get_value("Vehicle Fine Record", "REF-B", "status"), "PENDING") + sendmail.assert_called_once() + + with patch("requests.post", return_value=tpf_response([])), patch("frappe.sendmail") as sendmail: + self.assertEqual(get_fine(PLATE), []) + self.assertEqual(frappe.db.get_value("Vehicle Fine Record", "REF-B", "status"), "PAID") + sendmail.assert_called_once() + + def test_sync_without_references_returns_success(self): + with patch("requests.post", return_value=tpf_response([{"charge": 1}])): + result = sync_vehicle_fines(PLATE) + self.assertEqual(result["message"], "No fine references") + + def test_rate_limited_and_request_errors(self): + with patch("requests.post", return_value=tpf_response([], status_code=429)): + self.assertEqual(sync_vehicle_fines(PLATE)["status"], "rate_limited") + + with patch("requests.post", side_effect=requests.exceptions.ConnectionError("down")): + result = sync_vehicle_fines(PLATE) + self.assertEqual(result["status"], "retryable_error") + self.assertEqual(result["message"], "down") + + def test_invalid_json_is_logged(self): + response = MagicMock(status_code=200, text="") + response.json.side_effect = ValueError("bad json") + with patch("requests.post", return_value=response), patch("frappe.log_error") as log_error: + result = sync_vehicle_fines(PLATE) + self.assertEqual(result["status"], "error") + log_error.assert_called_once() + + def test_check_fine_all_vehicles_deduplicates_plates(self): + records = [ + frappe._dict(plate_number="t 555 eee"), + frappe._dict(plate_number="T555EEE"), + frappe._dict(plate_number="BAD"), + frappe._dict(plate_number="T666FFF"), + ] + with ( + patch.object(module, "get_vehicle_like_records", return_value=iter(records)), + patch.object(module, "get_fine") as get_fine_mock, + ): + result = check_fine_all_vehicles() + self.assertEqual(result["message"], "Processed fine checks for 2 unique vehicle-like records") + self.assertEqual( + [call.kwargs["number_plate"] for call in get_fine_mock.call_args_list], ["T555EEE", "T666FFF"] + ) + + def test_send_pending_vehicle_fine_notifications(self): + configure_authority_notifications( + ["Vehicle Fine"], vehicle_fine_notify_on_new=1, vehicle_fine_notify_on_status_change=1 + ) + record = frappe.get_doc( + { + "doctype": "Vehicle Fine Record", + "reference": "REF-PENDING", + "vehicle": "T445DDD", + "status": "PENDING", + } + ).insert() + frappe.db.set_value("Vehicle Fine Record", record.name, "authority_last_notified_status", "OLD") + + with patch("frappe.sendmail") as sendmail: + send_pending_vehicle_fine_notifications() + record.reload() + self.assertIsNotNone(record.authority_notified_on_new) + self.assertEqual(record.authority_last_notified_status, "PENDING") + self.assertEqual(sendmail.call_count, 2) + for call in sendmail.call_args_list: + self.assertEqual(call.kwargs["recipients"], [AUTHORITY_USER]) + + def test_validate_clears_vehicle_doc_for_unknown_plate(self): + record = frappe.get_doc( + {"doctype": "Vehicle Fine Record", "reference": "REF-UNKNOWN", "vehicle": "T777GGG"} + ).insert() + self.assertIsNone(record.vehicle_doc) From bf3aeca9bbdf2bb591c7602e8eb619c5be8c4055 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:00 +0300 Subject: [PATCH 59/74] test: cover the LATRA licence and offence sync --- csf_tz/tests/test_vehicle_latra.py | 429 +++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 csf_tz/tests/test_vehicle_latra.py diff --git a/csf_tz/tests/test_vehicle_latra.py b/csf_tz/tests/test_vehicle_latra.py new file mode 100644 index 00000000..574e8faf --- /dev/null +++ b/csf_tz/tests/test_vehicle_latra.py @@ -0,0 +1,429 @@ +from unittest.mock import patch + +import frappe +import requests +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, nowdate + +from csf_tz.csf_tz.doctype.latra_licenses import latra_licenses as module +from csf_tz.csf_tz.doctype.latra_licenses.latra_licenses import ( + TOKEN_EXPIRED, + notify_latra_license_expiry, + send_pending_latra_offence_notifications, + sync_all_latra_licenses, + update_latra_offences, + update_latra_records, +) +from csf_tz.tests.integration_fixtures import FakeResponse +from csf_tz.tests.test_vehicle_authority import AUTHORITY_USER, configure_authority_notifications + +PLATES = ["T444LLL", "T555LLL"] + + +def license_row(plate, number, valid_to, license_type="GOODSCARRYINGVEHICLE"): + return { + "licenseNumber": number, + "licenseStatus": "ACTIVE", + "validFrom": "2026-01-01T00:00:00", + "validTo": valid_to, + "serviceType": {"name": "Cargo", "licenseType": {"licenseTypeName": license_type}}, + "licenseInfoDetail": { + "currentLicenseApplication": {"branch": {"name": "Dar", "district": {"districtName": "Ilala"}}} + }, + "vehicle": {"vehicleRegistrationNumber": plate}, + } + + +def offence_row(plate, reference, status="UNPAID"): + return { + "uid": f"uid-{reference}", + "vehicleRegistrationNumber": plate, + "offenceReferenceNumber": reference, + "offenderName": "Driver", + "offenceDate": "2026-02-01T10:00:00", + "paymentStatus": status, + "amount": 50000, + "clientOffenceType": "WARNING", + "warningDescription": "", + "offence": {"name": "Overloading", "compoundedAmount": 50000}, + "offenceLocation": {"name": "Morogoro"}, + } + + +def license_page(rows): + return FakeResponse(200, {"data": {"findMyCurrentLicensesPageable": {"content": rows}}}) + + +def offence_page(rows, total=None): + return FakeResponse( + 200, + {"data": {"allMyClientOffencesPageable": {"content": rows, "totalElements": total or len(rows)}}}, + ) + + +def set_latra_settings(username="user@example.com", password="secret", access_token=""): + settings = frappe.get_single("Latra Settings") + settings.username = username + settings.password = password + settings.access_token = access_token + settings.save(ignore_permissions=True) + frappe.cache().delete_value(module._token_cache_key()) + + +class TestLatraHelpers(IntegrationTestCase): + def test_parse_date(self): + self.assertIsNone(module._parse_date(None)) + self.assertEqual(module._parse_date("2026-01-05T00:00:00"), "2026-01-05") + self.assertEqual(module._parse_date("2026-01-05"), "2026-01-05") + + def test_place_issued_and_license_type(self): + row = license_row("T444LLL", "L1", "2026-12-31") + self.assertEqual(module._get_place_issued(row), "Dar (Ilala)") + self.assertEqual(module._get_place_issued({"licenseInfoDetail": {}}), "") + self.assertEqual(module._get_license_type(row), "GCV") + self.assertEqual(module._get_license_type(license_row("x", "y", "z", "PRIVATEHIRE")), "Private Hire") + self.assertEqual(module._get_license_type(license_row("x", "y", "z", "OTHER")), "OTHER") + + def test_log_sync_summary(self): + with patch("frappe.log_error") as log_error: + module._log_sync_summary({"message": "a"}, "b") + log_error.assert_called_once_with(title="LATRA Sync Summary", message="Licenses: a\nOffences: b") + + +class TestLatraToken(IntegrationTestCase): + def setUp(self): + set_latra_settings() + + def test_get_token_reads_settings_and_caches(self): + self.assertEqual(module._get_token(), "") + set_latra_settings(access_token=" tok ") + self.assertEqual(module._get_token(), "tok") + frappe.db.set_single_value("Latra Settings", "access_token", "other") + self.assertEqual(module._get_token(), "tok") + + def test_get_token_logs_errors(self): + with ( + patch("frappe.db.get_single_value", side_effect=RuntimeError("db")), + patch("frappe.log_error") as log, + ): + self.assertIsNone(module._get_token()) + log.assert_called_once() + + def test_refresh_token_without_credentials(self): + frappe.db.set_single_value("Latra Settings", "username", "") + with patch("frappe.log_error") as log_error: + self.assertIsNone(module._refresh_token_locked()) + self.assertEqual(log_error.call_args.kwargs["title"], "LATRA: Credentials missing") + + def test_refresh_token_success(self): + response = FakeResponse(200, {"data": {"accessToken": " new-token "}}) + with patch("requests.post", return_value=response) as post: + self.assertEqual(module._refresh_token(), "new-token") + self.assertEqual( + post.call_args.kwargs["data"], {"username": "user@example.com", "password": "secret"} + ) + self.assertEqual(frappe.db.get_single_value("Latra Settings", "access_token"), "new-token") + self.assertEqual(module._get_token(), "new-token") + + def test_refresh_token_failures(self): + with patch("requests.post", return_value=FakeResponse(401, {}, text="denied")): + self.assertIsNone(module._refresh_token_locked()) + with patch("requests.post", return_value=FakeResponse(200, {"data": {}})): + self.assertIsNone(module._refresh_token_locked()) + with patch("requests.post", side_effect=requests.exceptions.ConnectionError("down")): + self.assertIsNone(module._refresh_token_locked()) + + def test_refresh_token_reuses_token_refreshed_by_another_worker(self): + set_latra_settings(access_token="fresh") + with patch("requests.post") as post: + self.assertEqual(module._refresh_token(old_token="stale"), "fresh") + post.assert_not_called() + + +class TestLatraApiCalls(IntegrationTestCase): + def test_call_license_page_statuses(self): + with patch("requests.post", return_value=license_page([{"licenseNumber": "L1"}])): + self.assertEqual( + module._call_license_page("tok"), + {"findMyCurrentLicensesPageable": {"content": [{"licenseNumber": "L1"}]}}, + ) + with patch("requests.post", return_value=FakeResponse(401, {})): + self.assertEqual(module._call_license_page("tok"), TOKEN_EXPIRED) + with patch("requests.post", return_value=FakeResponse(429, {})): + self.assertEqual(module._call_license_page("tok"), "RATE_LIMITED") + with patch("requests.post", return_value=FakeResponse(500, {})): + self.assertIsNone(module._call_license_page("tok")) + with patch("requests.post", return_value=FakeResponse(400, {})), patch("frappe.log_error") as log: + self.assertIsNone(module._call_license_page("tok")) + log.assert_called_once() + with patch("requests.post", side_effect=requests.exceptions.Timeout()): + self.assertIsNone(module._call_license_page("tok")) + with patch("requests.post", return_value=FakeResponse(200, {"errors": [{"message": "bad"}]})): + self.assertIsNone(module._call_license_page("tok")) + with patch("requests.post", return_value=FakeResponse(200, None, text="")): + self.assertIsNone(module._call_license_page("tok")) + + def test_fetch_all_licenses_paginates(self): + first = [license_row("T444LLL", f"L{i}", "2026-12-31") for i in range(200)] + second = [license_row("T555LLL", "L200", "2026-12-31")] + with patch("requests.post", side_effect=[license_page(first), license_page(second)]) as post: + rows = module._fetch_all_licenses("tok") + self.assertEqual(len(rows), 201) + self.assertEqual(post.call_count, 2) + self.assertEqual(post.call_args_list[1].kwargs["json"]["variables"]["pageableParam"]["first"], 1) + + with patch("requests.post", return_value=FakeResponse(401, {})): + self.assertEqual(module._fetch_all_licenses("tok"), TOKEN_EXPIRED) + + def test_call_offences_page_statuses(self): + with patch("requests.post", return_value=offence_page([offence_row("T444LLL", "R1")])): + result = module._call_offences_graphql_page("tok") + self.assertEqual(result["allMyClientOffencesPageable"]["totalElements"], 1) + with patch("requests.post", return_value=FakeResponse(401, {})): + self.assertEqual(module._call_offences_graphql_page("tok"), TOKEN_EXPIRED) + with patch("requests.post", return_value=FakeResponse(429, {})): + self.assertEqual(module._call_offences_graphql_page("tok"), "RATE_LIMITED") + with patch("requests.post", return_value=FakeResponse(503, {})): + self.assertIsNone(module._call_offences_graphql_page("tok")) + with patch("requests.post", return_value=FakeResponse(403, {})): + self.assertIsNone(module._call_offences_graphql_page("tok")) + with patch("requests.post", side_effect=requests.exceptions.ConnectionError()): + self.assertIsNone(module._call_offences_graphql_page("tok")) + with patch("requests.post", side_effect=requests.exceptions.InvalidURL()): + self.assertIsNone(module._call_offences_graphql_page("tok")) + with patch("requests.post", return_value=FakeResponse(200, {"errors": ["x"]})): + self.assertIsNone(module._call_offences_graphql_page("tok")) + with patch("requests.post", return_value=FakeResponse(200, None, text="oops")): + self.assertIsNone(module._call_offences_graphql_page("tok")) + + def test_fetch_all_offences_paginates(self): + first = [offence_row("T444LLL", f"R{i}") for i in range(500)] + second = [offence_row("T444LLL", "R500")] + with patch("requests.post", side_effect=[offence_page(first, 501), offence_page(second, 501)]): + rows = module._fetch_all_offences("tok") + self.assertEqual(len(rows), 501) + with patch("requests.post", return_value=offence_page([])): + self.assertEqual(module._fetch_all_offences("tok"), []) + with patch("requests.post", return_value=FakeResponse(429, {})): + self.assertEqual(module._fetch_all_offences("tok"), "RATE_LIMITED") + + +class TestLatraLicenseSync(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.commit_patch = patch.object(frappe.db, "commit") + cls.commit_patch.start() + cls.addClassCleanup(cls.commit_patch.stop) + cls.plates_patch = patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES)) + cls.plates_patch.start() + cls.addClassCleanup(cls.plates_patch.stop) + + def setUp(self): + set_latra_settings(access_token="tok") + + def test_sync_all_latra_licenses_upserts_latest_license(self): + rows = [ + license_row("t 444 lll", "LIC-OLD", "2026-06-30"), + license_row("T444LLL", "LIC-NEW", "2027-06-30"), + license_row("T999ZZZ", "LIC-OTHER", "2027-06-30"), + {"licenseNumber": "LIC-NOVEHICLE"}, + ] + with patch("requests.post", return_value=license_page(rows)): + result = sync_all_latra_licenses("tok") + self.assertEqual( + (result["matched"], result["skipped"], result["saved"], result["processed"]), (1, 1, 1, 2) + ) + self.assertTrue(result["completed_cycle"]) + license_doc = frappe.get_doc("Latra Licenses", "LIC-NEW") + self.assertEqual(license_doc.vehicle, "T444LLL") + self.assertEqual(license_doc.license_type, "GCV") + self.assertEqual(license_doc.service_type, "Cargo") + self.assertEqual(license_doc.place_issued, "Dar (Ilala)") + self.assertEqual(str(license_doc.expire_date), "2027-06-30") + self.assertFalse(frappe.db.exists("Latra Licenses", "LIC-OLD")) + + rows[1]["licenseStatus"] = "EXPIRED" + with patch("requests.post", return_value=license_page(rows)): + result = sync_all_latra_licenses("tok") + self.assertEqual(result["saved"], 1) + self.assertEqual(frappe.db.get_value("Latra Licenses", "LIC-NEW", "license_status"), "EXPIRED") + + def test_sync_all_latra_licenses_propagates_api_state(self): + with patch("requests.post", return_value=FakeResponse(401, {})): + self.assertEqual(sync_all_latra_licenses("tok"), TOKEN_EXPIRED) + with patch("requests.post", return_value=FakeResponse(500, {})): + self.assertIsNone(sync_all_latra_licenses("tok")) + + def test_upsert_latra_license_edge_cases(self): + self.assertEqual(module._upsert_latra_license("T444LLL", {}), 0) + with patch("frappe.db.exists", side_effect=RuntimeError("db")), patch("frappe.log_error") as log: + self.assertEqual(module._upsert_latra_license("T444LLL", {"licenseNumber": "X"}), 0) + log.assert_called_once() + + def test_update_latra_records_flow(self): + set_latra_settings(access_token="") + with patch.object(module, "_refresh_token", return_value=None): + self.assertIn("no token", update_latra_records()["message"]) + + set_latra_settings(access_token="tok") + with patch( + "requests.post", return_value=license_page([license_row("T444LLL", "LIC-A", "2027-01-01")]) + ): + result = update_latra_records() + self.assertEqual(result["licenses"]["saved"], 1) + + with ( + patch.object(module, "sync_all_latra_licenses", return_value=TOKEN_EXPIRED), + patch.object(module, "_refresh_token", return_value=None), + ): + self.assertIn("token refresh failed", update_latra_records()["message"]) + with ( + patch.object(module, "sync_all_latra_licenses", return_value=TOKEN_EXPIRED), + patch.object(module, "_refresh_token", return_value="tok2"), + ): + self.assertIn("auth failure", update_latra_records()["message"]) + with patch.object(module, "sync_all_latra_licenses", return_value="RATE_LIMITED"): + self.assertIn("throttling", update_latra_records()["message"]) + + def test_notify_latra_license_expiry(self): + configure_authority_notifications(["LATRA License"], latra_license_notify_before_days=7) + for number, days in (("LIC-EXPIRED", -1), ("LIC-SOON", 3), ("LIC-FAR", 30)): + frappe.get_doc( + { + "doctype": "Latra Licenses", + "license_number": number, + "vehicle": "T444LLL", + "expire_date": add_days(nowdate(), days), + } + ).insert() + frappe.get_doc({"doctype": "Latra Licenses", "license_number": "LIC-NODATE"}).insert() + + with patch("frappe.sendmail") as sendmail: + notify_latra_license_expiry() + self.assertEqual(sendmail.call_count, 2) + subjects = sorted(call.kwargs["subject"] for call in sendmail.call_args_list) + self.assertTrue(subjects[0].startswith("LATRA License Expired")) + self.assertTrue(subjects[1].startswith("LATRA License Expiry Reminder")) + self.assertTrue( + frappe.db.get_value( + "Latra Licenses", "LIC-EXPIRED", "authority_last_expiry_notification_key" + ).startswith("expired:") + ) + self.assertIsNone( + frappe.db.get_value("Latra Licenses", "LIC-FAR", "authority_last_expiry_notification_key") + ) + + with patch("frappe.sendmail") as sendmail: + notify_latra_license_expiry() + sendmail.assert_not_called() + + +class TestLatraOffenceSync(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.commit_patch = patch.object(frappe.db, "commit") + cls.commit_patch.start() + cls.addClassCleanup(cls.commit_patch.stop) + + def setUp(self): + set_latra_settings(access_token="tok") + frappe.db.delete("Latra Offence") + + def test_update_latra_offences_without_plates(self): + with patch.object(module, "get_unique_vehicle_plates", return_value=[]): + result = update_latra_offences() + self.assertEqual(result["processed"], 0) + self.assertTrue(result["completed_cycle"]) + + def test_update_latra_offences_creates_updates_and_notifies(self): + configure_authority_notifications( + ["LATRA Offence"], latra_offence_notify_on_new=1, latra_offence_notify_on_status_change=1 + ) + rows = [ + offence_row("t-444-lll", "REF-1"), + offence_row("T444LLL", "REF-2"), + offence_row("T888ZZZ", "REF-3"), + ] + with ( + patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES)), + patch("requests.post", return_value=offence_page(rows)), + patch("frappe.sendmail") as sendmail, + ): + result = update_latra_offences() + self.assertEqual( + (result["processed"], result["saved"], result["skipped"], result["matched"]), (2, 2, 1, 1) + ) + self.assertEqual(result["fetched_offences"], 3) + self.assertEqual(sendmail.call_count, 2) + self.assertEqual(sendmail.call_args.kwargs["recipients"], [AUTHORITY_USER]) + + offence = frappe.get_doc("Latra Offence", {"reference_number": "REF-1"}) + self.assertEqual(offence.mv_reg_number, "T444LLL") + self.assertEqual(offence.offence, "Overloading") + self.assertEqual(offence.location, "Morogoro") + self.assertEqual(str(offence.offence_date), "2026-02-01") + self.assertEqual(offence.amount, 50000) + self.assertEqual(offence.authority_last_notified_status, "UNPAID") + self.assertIsNotNone(offence.authority_notified_on_new) + + rows[0]["paymentStatus"] = "PAID" + with ( + patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES)), + patch("requests.post", return_value=offence_page(rows)), + patch("frappe.sendmail") as sendmail, + ): + result = update_latra_offences() + self.assertEqual(result["saved"], 2) + self.assertEqual(frappe.db.count("Latra Offence"), 2) + sendmail.assert_called_once() + self.assertIn("from UNPAID to PAID", sendmail.call_args.kwargs["message"]) + offence.reload() + self.assertEqual(offence.status, "PAID") + self.assertEqual(offence.authority_last_notified_status, "PAID") + + def test_update_latra_offences_api_failures(self): + with patch.object(module, "get_unique_vehicle_plates", return_value=list(PLATES)): + with patch("requests.post", return_value=FakeResponse(429, {})): + self.assertIn("throttling", update_latra_offences()["message"]) + with patch("requests.post", return_value=FakeResponse(500, {})), patch("frappe.log_error"): + self.assertIn("upstream failure", update_latra_offences()["message"]) + with ( + patch("requests.post", return_value=FakeResponse(401, {})), + patch.object(module, "_refresh_token", return_value=None), + patch("frappe.log_error"), + ): + self.assertIn("upstream failure", update_latra_offences()["message"]) + set_latra_settings(access_token="") + with patch.object(module, "_refresh_token", return_value=None), patch("frappe.log_error"): + self.assertIn("no token", update_latra_offences()["message"]) + + def test_send_pending_latra_offence_notifications(self): + configure_authority_notifications( + ["LATRA Offence"], latra_offence_notify_on_new=1, latra_offence_notify_on_status_change=1 + ) + offence = frappe.get_doc( + { + "doctype": "Latra Offence", + "mv_reg_number": "T444LLL", + "reference_number": "REF-P", + "status": "UNPAID", + "amount": 10, + } + ).insert() + frappe.db.set_value("Latra Offence", offence.name, "authority_last_notified_status", "PENDING") + with patch("frappe.sendmail") as sendmail: + send_pending_latra_offence_notifications() + self.assertEqual(sendmail.call_count, 2) + offence.reload() + self.assertIsNotNone(offence.authority_notified_on_new) + self.assertEqual(offence.authority_last_notified_status, "UNPAID") + + def test_notify_latra_offence_disabled_events(self): + configure_authority_notifications(["LATRA Offence"]) + with patch("frappe.sendmail") as sendmail: + module._notify_latra_offence("x", {"status": "UNPAID"}, is_new=True) + module._notify_latra_offence("x", {"status": "PAID"}, is_new=False, old_status="UNPAID") + module._notify_latra_offence("x", {"status": "PAID"}, is_new=False, old_status="PAID") + sendmail.assert_not_called() From 063a4df3f53d20ed87e6bb5b3d9d97ad9fd41950 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:05 +0300 Subject: [PATCH 60/74] test: cover the vehicle sync queue and processor --- csf_tz/tests/test_vehicle_sync_task.py | 203 +++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 csf_tz/tests/test_vehicle_sync_task.py diff --git a/csf_tz/tests/test_vehicle_sync_task.py b/csf_tz/tests/test_vehicle_sync_task.py new file mode 100644 index 00000000..51e5d424 --- /dev/null +++ b/csf_tz/tests/test_vehicle_sync_task.py @@ -0,0 +1,203 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_to_date, get_datetime, now_datetime + +from csf_tz.csf_tz.doctype.vehicle_sync_task import processor, queue +from csf_tz.csf_tz.doctype.vehicle_sync_task.vehicle_sync_task import VehicleSyncTask + +TASK = "Vehicle Sync Task" + + +def task_values(name, *fields): + return frappe.db.get_value(TASK, name, list(fields), as_dict=True) + + +class TestVehicleSyncQueue(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.commit_patch = patch.object(frappe.db, "commit") + cls.commit_patch.start() + cls.addClassCleanup(cls.commit_patch.stop) + + def setUp(self): + frappe.db.delete(TASK) + frappe.cache().delete_value(processor.RATE_LIMIT_CACHE_KEY) + + def test_create_sync_task_normalizes_and_deduplicates(self): + name = processor.create_sync_task("t 100 aaa") + values = task_values(name, "vehicle_no", "status", "next_run_at", "priority") + self.assertEqual(values.vehicle_no, "T100AAA") + self.assertEqual(values.status, "Pending") + self.assertIsNone(values.next_run_at) + + self.assertEqual(processor.create_sync_task("T100AAA"), name) + self.assertEqual(task_values(name, "priority").priority, 0) + + self.assertEqual(processor.create_sync_task("T100AAA", immediate=True), name) + values = task_values(name, "next_run_at", "priority") + self.assertIsNotNone(values.next_run_at) + self.assertEqual(values.priority, 5) + + self.assertEqual(processor.create_sync_task("T100AAA", priority=9), name) + self.assertEqual(task_values(name, "priority").priority, 9) + + def test_create_sync_task_rejects_invalid_plate(self): + self.assertIsNone(processor.create_sync_task("BAD")) + self.assertIsNone(processor.create_sync_task(None)) + + def test_create_sync_task_reactivates_deleted_task(self): + name = processor.create_sync_task("T101AAA") + frappe.db.set_value(TASK, name, {"is_deleted": 1, "status": "Failed", "last_error": "x"}) + + self.assertEqual(processor.create_sync_task("T101AAA", priority=2, immediate=True), name) + values = task_values(name, "is_deleted", "status", "priority", "last_error", "next_run_at") + self.assertEqual(values.is_deleted, 0) + self.assertEqual(values.status, "Pending") + self.assertEqual(values.priority, 2) + self.assertEqual(values.last_error, "") + self.assertIsNotNone(values.next_run_at) + + def test_claim_batch_prefers_priority_and_marks_processing(self): + low = processor.create_sync_task("T102AAA", priority=0) + high = processor.create_sync_task("T103AAA", priority=3) + + claimed = queue.claim_batch(TASK, limit=1) + self.assertEqual([row["name"] for row in claimed], [high]) + self.assertEqual(claimed[0]["vehicle_no"], "T103AAA") + values = task_values(high, "status", "claimed_by", "claimed_at", "last_run_at") + self.assertEqual(values.status, "Processing") + self.assertEqual(values.claimed_by, queue.WORKER_ID) + self.assertIsNotNone(values.claimed_at) + self.assertIsNotNone(values.last_run_at) + + self.assertEqual([row["name"] for row in queue.claim_batch(TASK, limit=5)], [low]) + self.assertEqual(queue.claim_batch(TASK), []) + + def test_claim_batch_skips_future_and_deleted_tasks(self): + future = processor.create_sync_task("T104AAA") + frappe.db.set_value(TASK, future, "next_run_at", add_to_date(now_datetime(), hours=1)) + deleted = processor.create_sync_task("T105AAA") + frappe.db.set_value(TASK, deleted, "is_deleted", 1) + self.assertEqual(queue.claim_batch(TASK, limit=5), []) + + def test_claim_batch_falls_back_to_failed_tasks(self): + name = processor.create_sync_task("T106AAA") + queue.mark_failed(TASK, {"name": name}, "boom " * 500) + values = task_values(name, "status", "last_error", "claimed_by", "next_run_at") + self.assertEqual(values.status, "Failed") + self.assertEqual(len(values.last_error), 1000) + self.assertEqual(values.claimed_by, "") + self.assertIsNotNone(values.next_run_at) + + self.assertEqual([row["name"] for row in queue.claim_batch(TASK)], [name]) + + def test_mark_done_schedules_next_run(self): + name = processor.create_sync_task("T107AAA") + queue.claim_batch(TASK) + queue.mark_done(TASK, {"name": name}) + values = task_values(name, "status", "claimed_by", "claimed_at", "next_run_at", "last_error") + self.assertEqual(values.status, "Pending") + self.assertEqual(values.claimed_by, "") + self.assertIsNone(values.claimed_at) + self.assertEqual(values.last_error, "") + seconds_ahead = (get_datetime(values.next_run_at) - now_datetime()).total_seconds() + self.assertGreater(seconds_ahead, queue.SUCCESS_INTERVAL_SECONDS - 120) + + def test_reset_stuck_tasks(self): + stuck = processor.create_sync_task("T108AAA") + fresh = processor.create_sync_task("T109AAA") + frappe.db.set_value( + TASK, stuck, {"status": "Processing", "claimed_at": add_to_date(now_datetime(), minutes=-30)} + ) + frappe.db.set_value(TASK, fresh, {"status": "Processing", "claimed_at": now_datetime()}) + + self.assertEqual(queue.reset_stuck_tasks(TASK, timeout_minutes=10), 1) + self.assertEqual(task_values(stuck, "status", "claimed_by").status, "Pending") + self.assertEqual(task_values(fresh, "status").status, "Processing") + + def test_queue_helpers_log_errors_on_bad_doctype(self): + with patch("frappe.log_error") as log_error: + self.assertEqual(queue.claim_batch("No Such DocType"), []) + queue.mark_done("No Such DocType", {"name": "x"}) + queue.mark_failed("No Such DocType", {"name": "x"}, "err") + self.assertEqual(queue.reset_stuck_tasks("No Such DocType"), 0) + self.assertEqual(log_error.call_count, 4) + + def test_rate_limit_slot(self): + self.assertTrue(processor._acquire_rate_limit_slot()) + for _ in range(queue.MAX_CALLS_PER_MINUTE - 1): + self.assertTrue(processor._acquire_rate_limit_slot()) + self.assertFalse(processor._acquire_rate_limit_slot()) + + def test_run_vehicle_batch_without_tasks(self): + self.assertEqual(processor.run_vehicle_batch()["status"], "no_tasks") + + def test_run_vehicle_batch_success_and_failure(self): + name = processor.create_sync_task("T110AAA") + with patch.object(processor, "sync_vehicle_fines", return_value={"status": "success"}) as sync: + result = processor.run_vehicle_batch() + sync.assert_called_once_with("T110AAA") + self.assertEqual(result, {"status": "completed", "processed": 1, "errors": 0, "claimed": 1}) + self.assertEqual(task_values(name, "status").status, "Pending") + + frappe.db.set_value(TASK, name, "next_run_at", None) + frappe.cache().delete_value(processor.RATE_LIMIT_CACHE_KEY) + with patch.object( + processor, "sync_vehicle_fines", return_value={"status": "retryable_error", "message": "timeout"} + ): + result = processor.run_vehicle_batch() + self.assertEqual(result["errors"], 1) + values = task_values(name, "status", "last_error") + self.assertEqual(values.status, "Failed") + self.assertEqual(values.last_error, "timeout") + + def test_run_vehicle_batch_respects_rate_limit(self): + name = processor.create_sync_task("T111AAA") + with ( + patch.object(processor, "_acquire_rate_limit_slot", return_value=False), + patch.object(processor, "sync_vehicle_fines") as sync, + ): + result = processor.run_vehicle_batch() + sync.assert_not_called() + self.assertEqual(result["processed"], 0) + self.assertIn("limit", task_values(name, "last_error").last_error) + + def test_seed_vehicle_sync_queue(self): + def records(plates): + return lambda: iter(frappe._dict(plate_number=plate) for plate in plates) + + with patch.object( + processor, "get_vehicle_like_records", records(["T120AAA", "t 120 aaa", "T121AAA", "X"]) + ): + result = processor.seed_vehicle_sync_queue() + self.assertEqual((result["status"], result["created"], result["total_vehicles"]), ("success", 2, 2)) + + with patch.object(processor, "get_vehicle_like_records", records(["T120AAA"])): + result = processor.seed_vehicle_sync_queue() + self.assertEqual((result["skipped"], result["deleted_marked"]), (1, 1)) + self.assertEqual(frappe.db.get_value(TASK, {"vehicle_no": "T121AAA"}, "is_deleted"), 1) + + with patch.object(processor, "get_vehicle_like_records", records(["T120AAA", "T121AAA"])): + result = processor.seed_vehicle_sync_queue() + self.assertEqual((result["skipped"], result["reactivated"]), (1, 1)) + self.assertEqual(frappe.db.get_value(TASK, {"vehicle_no": "T121AAA"}, "is_deleted"), 0) + + def test_seed_vehicle_sync_queue_reports_errors(self): + with ( + patch.object(processor, "get_vehicle_like_records", side_effect=RuntimeError("meta")), + patch("frappe.log_error") as log_error, + ): + result = processor.seed_vehicle_sync_queue() + self.assertEqual(result, {"status": "error", "message": "meta"}) + log_error.assert_called_once() + + def test_clear_old_logs(self): + old = processor.create_sync_task("T130AAA") + recent = processor.create_sync_task("T131AAA") + frappe.db.set_value(TASK, old, "creation", add_to_date(now_datetime(), days=-10)) + VehicleSyncTask.clear_old_logs(days=7) + self.assertFalse(frappe.db.exists(TASK, old)) + self.assertTrue(frappe.db.exists(TASK, recent)) From 338f2162729d0e2eb10de27c703062f249a85b8a Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:10 +0300 Subject: [PATCH 61/74] test: cover the TIRA cover note sync --- csf_tz/tests/test_vehicle_tira_covernote.py | 187 ++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 csf_tz/tests/test_vehicle_tira_covernote.py diff --git a/csf_tz/tests/test_vehicle_tira_covernote.py b/csf_tz/tests/test_vehicle_tira_covernote.py new file mode 100644 index 00000000..ce460882 --- /dev/null +++ b/csf_tz/tests/test_vehicle_tira_covernote.py @@ -0,0 +1,187 @@ +import json +from unittest.mock import patch + +import frappe +import requests +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, nowdate + +from csf_tz.csf_tz.doctype.tz_insurance_cover_note import tz_insurance_cover_note as module +from csf_tz.csf_tz.doctype.tz_insurance_cover_note.tz_insurance_cover_note import ( + fetch_and_update_covernote, + get_covernote_details, + notify_tira_covernote_expiry, + update_covernote_docs, +) +from csf_tz.tests.integration_fixtures import FakeResponse +from csf_tz.tests.test_vehicle_authority import AUTHORITY_USER, configure_authority_notifications + +PLATE = "T444TTT" +JAN_2024 = 1704067200000 + + +def tira_record(cover_note_number="CN-001", status="Active"): + return { + "coverNoteNumber": cover_note_number, + "coverNoteStartDate": JAN_2024, + "coverNoteEndDate": 1735603200000, + "statusTitle": status, + "currencyCode": "TZS", + "totalPremiumAmountIncludingTax": 150000, + "isMotor": True, + "motor": { + "registrationNumber": PLATE, + "make": "Toyota", + "createdDate": JAN_2024, + "updatedDate": None, + }, + "company": { + "companyName": "Insurer Ltd", + "incorporationDate": JAN_2024, + "shareholders": [{"name": "Owner"}], + }, + "policyHolders": [{"policyHolderFullName": "John Doe", "policyHolderBirthDate": 631152000000}], + } + + +class TestGetCovernoteDetails(IntegrationTestCase): + def test_success_returns_json(self): + with patch("requests.post", return_value=FakeResponse(200, {"data": []})) as post: + self.assertEqual(get_covernote_details(PLATE), {"data": []}) + self.assertEqual(json.loads(post.call_args.kwargs["data"]), {"paramType": 2, "searchParam": PLATE}) + + def test_client_error_is_logged_without_retry(self): + with ( + patch("requests.post", return_value=FakeResponse(404, {}, text="missing")) as post, + patch("frappe.log_error") as log_error, + ): + self.assertIsNone(get_covernote_details(PLATE)) + self.assertEqual(post.call_count, 1) + self.assertEqual(log_error.call_args.kwargs["title"], "Tiramis API Error") + + def test_server_errors_and_timeouts_are_retried(self): + with ( + patch("requests.post", return_value=FakeResponse(500, {})) as post, + patch.object(module, "sleep"), + ): + self.assertIsNone(get_covernote_details(PLATE)) + self.assertEqual(post.call_count, 3) + + with ( + patch("requests.post", side_effect=requests.exceptions.Timeout()) as post, + patch.object(module, "sleep") as sleep, + ): + self.assertIsNone(get_covernote_details(PLATE)) + self.assertEqual(post.call_count, 3) + self.assertEqual(sleep.call_count, 2) + + with ( + patch("requests.post", side_effect=requests.exceptions.InvalidURL()), + patch("frappe.log_error") as log, + ): + self.assertIsNone(get_covernote_details(PLATE)) + log.assert_called_once() + + def test_invalid_json_is_logged(self): + with ( + patch("requests.post", return_value=FakeResponse(200, None, text="")), + patch("frappe.log_error") as log_error, + ): + self.assertIsNone(get_covernote_details(PLATE)) + self.assertEqual(log_error.call_args.kwargs["title"], "Tiramis API: Invalid JSON") + + +class TestCovernoteSync(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.commit_patch = patch.object(frappe.db, "commit") + cls.commit_patch.start() + cls.addClassCleanup(cls.commit_patch.stop) + + def test_fetch_and_update_covernote_creates_and_updates(self): + with patch.object(module, "get_covernote_details", return_value={"data": [tira_record()]}): + fetch_and_update_covernote(PLATE) + + doc = frappe.get_doc("TZ Insurance Cover Note", "CN-001") + self.assertEqual(doc.vehicle, PLATE) + self.assertEqual(doc.statustitle, "Active") + self.assertEqual(doc.covernotestartdate, "2024-01-01 00:00:00") + self.assertEqual(doc.covernoteenddate, "2024-12-31 00:00:00") + self.assertEqual(doc.totalpremiumamountincludingtax, 150000) + self.assertEqual(doc.ismotor, 1) + self.assertEqual(len(doc.insurance_motors), 1) + self.assertEqual(doc.insurance_motors[0].registrationnumber, PLATE) + self.assertEqual(doc.insurance_motors[0].createddate, "2024-01-01 00:00:00") + self.assertIsNone(doc.insurance_motors[0].updateddate) + self.assertEqual(doc.insurance_provider[0].companyname, "Insurer Ltd") + self.assertEqual(doc.insurance_provider[0].incorporationdate, "2024-01-01 00:00:00") + self.assertEqual(json.loads(doc.insurance_provider[0].shareholders), [{"name": "Owner"}]) + self.assertEqual(doc.policy_holders[0].policyholderfullname, "John Doe") + self.assertEqual(doc.policy_holders[0].policyholderbirthdate, "1990-01-01 00:00:00") + + with patch.object( + module, "get_covernote_details", return_value={"data": [tira_record(status="Expired")]} + ): + fetch_and_update_covernote(PLATE) + doc.reload() + self.assertEqual(doc.statustitle, "Expired") + self.assertEqual(len(doc.insurance_motors), 1) + self.assertEqual(len(doc.policy_holders), 1) + + def test_fetch_and_update_covernote_ignores_empty_and_logs_bad_records(self): + with patch.object(module, "get_covernote_details", return_value=None): + fetch_and_update_covernote(PLATE) + with patch.object(module, "get_covernote_details", return_value={"data": []}): + fetch_and_update_covernote(PLATE) + with ( + patch.object(module, "get_covernote_details", return_value={"data": [{"statusTitle": "x"}]}), + patch("frappe.log_error") as log_error, + ): + fetch_and_update_covernote(PLATE) + log_error.assert_called_once() + + def test_update_covernote_docs_deduplicates_plates(self): + records = [ + frappe._dict(plate_number="t 444 ttt"), + frappe._dict(plate_number="T444TTT"), + frappe._dict(plate_number="BAD"), + frappe._dict(plate_number="T555TTT"), + ] + with ( + patch.object(module, "get_vehicle_like_records", return_value=iter(records)), + patch.object(module, "fetch_and_update_covernote") as fetch, + ): + result = update_covernote_docs() + self.assertEqual(result["message"], "Processed covernote updates for 2 vehicles") + self.assertEqual([call.args[0] for call in fetch.call_args_list], ["T444TTT", "T555TTT"]) + + def test_notify_tira_covernote_expiry(self): + configure_authority_notifications(["TIRA"], tira_notify_before_days=7) + frappe.db.delete("TZ Insurance Cover Note") + for number, days in (("CN-EXPIRED", -1), ("CN-SOON", 3), ("CN-FAR", 30)): + frappe.get_doc( + { + "doctype": "TZ Insurance Cover Note", + "covernotenumber": number, + "vehicle": PLATE, + "covernoteenddate": f"{add_days(nowdate(), days)} 00:00:00", + } + ).insert() + frappe.get_doc({"doctype": "TZ Insurance Cover Note", "covernotenumber": "CN-NODATE"}).insert() + + with patch("frappe.sendmail") as sendmail: + notify_tira_covernote_expiry() + self.assertEqual(sendmail.call_count, 2) + subjects = sorted(call.kwargs["subject"] for call in sendmail.call_args_list) + self.assertTrue(subjects[0].startswith("TIRA Cover Note Expired")) + self.assertTrue(subjects[1].startswith("TIRA Cover Note Expiry Reminder")) + self.assertEqual(sendmail.call_args.kwargs["recipients"], [AUTHORITY_USER]) + key = frappe.db.get_value( + "TZ Insurance Cover Note", "CN-SOON", "authority_last_expiry_notification_key" + ) + self.assertTrue(key.startswith("pre-expiry:")) + + with patch("frappe.sendmail") as sendmail: + notify_tira_covernote_expiry() + sendmail.assert_not_called() From 4e79b042678dde9ffc4ef62d23e8db7c354bf5c1 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:16 +0300 Subject: [PATCH 62/74] test: cover the EFD Z report and fiscal device --- csf_tz/tests/test_vfd_efd_z_report.py | 172 ++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 csf_tz/tests/test_vfd_efd_z_report.py diff --git a/csf_tz/tests/test_vfd_efd_z_report.py b/csf_tz/tests/test_vfd_efd_z_report.py new file mode 100644 index 00000000..af19b162 --- /dev/null +++ b/csf_tz/tests/test_vfd_efd_z_report.py @@ -0,0 +1,172 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, nowdate + +from csf_tz.tests.vfd_test_records import ( + EXEMPT_TEMPLATE, + make_customer, + make_item_tax_templates, + make_sales_invoice, +) + +FUTURE = f"{add_days(nowdate(), 1)} 00:00:00" + + +def make_device(location="HQ", make="Datecs", model="DP25"): + name = f"{location}-{make}-{model}" + if frappe.db.exists("Electronic Fiscal Device", name): + return frappe.get_doc("Electronic Fiscal Device", name) + return frappe.get_doc( + { + "doctype": "Electronic Fiscal Device", + "type": "Electronic Tax Register (ETR)", + "serial_no": f"SN-{name}", + "location": location, + "make": make, + "model": model, + } + ).insert() + + +def make_report(device, z_no="Z1", z_report_date_time=FUTURE, **values): + return frappe.get_doc( + { + "doctype": "EFD Z Report", + "electronic_fiscal_device": device.name, + "z_no": z_no, + "receipts_issued": 1, + "z_report_date_time": z_report_date_time, + **values, + } + ) + + +def row_for(report, invoice): + return next(row for row in report.efd_z_report_invoices if row.invoice_number == invoice.name) + + +class TestElectronicFiscalDevice(IntegrationTestCase): + def test_name_is_built_from_location_make_and_model(self): + device = make_device("Branch", "Incotex", "M1") + self.assertEqual(device.name, "Branch-Incotex-M1") + self.assertEqual(device.serial_no, "SN-Branch-Incotex-M1") + + +class TestEFDZReport(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_item_tax_templates() + make_customer() + cls.device = make_device() + cls.other_device = make_device("HQ", "Other", "X1") + cls.standard_invoice = make_sales_invoice(electronic_fiscal_device=cls.device.name, submit=True) + cls.exempt_invoice = make_sales_invoice(item_tax_template=EXEMPT_TEMPLATE, submit=True) + cls.other_invoice = make_sales_invoice(electronic_fiscal_device=cls.other_device.name, submit=True) + + def ticked_report(self, **values): + report = make_report(self.device, **values) + report.get_sales_invoice() + row_for(report, self.standard_invoice).include = 1 + report.update( + { + "total_turnover": 236, + "net_amount": 200, + "total_vat": 36, + "total_turnover_ex_sr": 0, + "total_turnover_ticked": 236, + "total_excluding_vat_ticked": 200, + "total_vat_ticked": 36, + "total_turnover_exempted__sp_relief_ticked": 0, + } + ) + return report + + def test_validate_requires_invoices(self): + self.assertRaisesRegex( + frappe.ValidationError, "No Sales Invoie Found", make_report(self.device).insert + ) + + def test_get_sales_invoice_fetches_unlinked_invoices_for_device(self): + report = make_report(self.device) + self.assertTrue(report.get_sales_invoice()) + fetched = {row.invoice_number for row in report.efd_z_report_invoices} + self.assertIn(self.standard_invoice.name, fetched) + self.assertIn(self.exempt_invoice.name, fetched) + self.assertNotIn(self.other_invoice.name, fetched) + + standard = row_for(report, self.standard_invoice) + self.assertEqual(standard.amt_excl_vat, 200) + self.assertEqual(standard.vat, 36) + self.assertEqual(standard.amt_ex__sr, 0) + self.assertEqual(standard.invoice_amount, 236) + self.assertEqual(standard.invoice_currency, "INR") + self.assertEqual(str(standard.invoice_date), nowdate()) + + exempt = row_for(report, self.exempt_invoice) + self.assertEqual(exempt.vat, 0) + self.assertEqual(exempt.amt_ex__sr, 200) + + def test_get_sales_invoice_respects_report_time(self): + report = make_report(self.device, z_report_date_time="2000-01-01 00:00:00") + self.assertRaisesRegex(frappe.ValidationError, "No Sales Invoice Fetch", report.get_sales_invoice) + + def test_get_number_of_ticked(self): + report = self.ticked_report() + self.assertEqual(report.get_number_of_ticked(), 1) + row_for(report, self.exempt_invoice).include = 1 + self.assertEqual(report.get_number_of_ticked(), 2) + + def test_submit_links_included_invoices_and_cancel_unlinks(self): + report = self.ticked_report() + report.insert() + self.assertEqual(report.name, f"{self.device.name}-Z1") + report.submit() + self.assertEqual(len(report.efd_z_report_invoices), 1) + self.assertEqual( + frappe.db.get_value("Sales Invoice", self.standard_invoice.name, "efd_z_report"), report.name + ) + self.assertFalse(frappe.db.get_value("Sales Invoice", self.exempt_invoice.name, "efd_z_report")) + + second = make_report(self.device, z_no="Z2") + second.get_sales_invoice() + fetched = {row.invoice_number for row in second.efd_z_report_invoices} + self.assertNotIn(self.standard_invoice.name, fetched) + + report.cancel() + self.assertFalse(frappe.db.get_value("Sales Invoice", self.standard_invoice.name, "efd_z_report")) + + def test_submit_throws_when_totals_do_not_match(self): + checks = { + "total_turnover": "not equal to Money Entered", + "net_amount": "Total Excluding VAT", + "total_vat": "Total VAT", + "total_turnover_ex_sr": "Sp. Relief", + } + for fieldname, message in checks.items(): + report = self.ticked_report(z_no=f"Z-{fieldname}") + report.set(fieldname, report.get(fieldname) + 5) + report.insert() + self.assertRaisesRegex(frappe.ValidationError, message, report.submit) + + def test_allowable_difference_tolerates_small_gap(self): + report = self.ticked_report(z_no="Z-tol", allowable_difference=10) + report.total_turnover = 240 + report.insert() + report.submit() + self.assertEqual(report.docstatus, 1) + report.cancel() + + def test_submit_throws_when_receipts_issued_mismatch(self): + report = self.ticked_report(z_no="Z-count", receipts_issued=2) + report.insert() + self.assertRaisesRegex(frappe.ValidationError, "Receipts Issued", report.submit) + + def test_submit_throws_when_invoice_already_linked(self): + report = self.ticked_report(z_no="Z-linked") + report.insert() + frappe.db.set_value("Sales Invoice", self.standard_invoice.name, "efd_z_report", "SOMEWHERE-ELSE") + self.addCleanup( + frappe.db.set_value, "Sales Invoice", self.standard_invoice.name, "efd_z_report", None + ) + self.assertRaisesRegex(frappe.ValidationError, "linked to EFD Z Report SOMEWHERE-ELSE", report.submit) From cc7ac0abc043752acec046382cd65f9b6027a064 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:22 +0300 Subject: [PATCH 63/74] test: cover the VFD providers with mocked http --- csf_tz/tests/test_vfd_providers.py | 591 +++++++++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 csf_tz/tests/test_vfd_providers.py diff --git a/csf_tz/tests/test_vfd_providers.py b/csf_tz/tests/test_vfd_providers.py new file mode 100644 index 00000000..3aff17ae --- /dev/null +++ b/csf_tz/tests/test_vfd_providers.py @@ -0,0 +1,591 @@ +import json +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.utils import add_days, add_to_date, get_datetime, now_datetime, nowdate + +from csf_tz.tests.vfd_test_records import ( + COMPANY, + EXEMPT_TEMPLATE, + ITEM, + PROVIDERS, + SECOND_ITEM, + SERIAL_INFO, + fake_response, + make_sales_invoice, + make_vfd_records, + set_company_provider, +) +from csf_tz.vfd_providers.doctype.simplify_vfd_settings import simplify_vfd_settings as simplify +from csf_tz.vfd_providers.doctype.total_vfd_setting import total_vfd_setting as total_vfd +from csf_tz.vfd_providers.doctype.vfdplus_settings import vfdplus_settings as vfdplus +from csf_tz.vfd_providers.utils import get_vat_amount +from csf_tz.vfd_support import utils as vfd_utils + +VFDPLUS_REQUEST = "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.requests.request" +VFDPLUS_SLEEP = "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.sleep" +TOTAL_REQUEST = "csf_tz.vfd_providers.doctype.total_vfd_setting.total_vfd_setting.requests.request" +TOTAL_SLEEP = "csf_tz.vfd_providers.doctype.total_vfd_setting.total_vfd_setting.sleep" +SIMPLIFY_REQUEST = "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.requests.request" +SIMPLIFY_SLEEP = "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.sleep" + +PLUS_RECEIPT = { + "msg_status": "OK", + "msg_code": 2000, + "msg_data": {"rctvnum": "ABC123", "idate": "2026-08-25", "itime": "10:20:30"}, +} +TOTAL_RECEIPT = { + "status": 200, + "rctvnum": "778899", + "verificationLink": "https://verify.tra.go.tz/778899_102030", + "localDate": "2026-08-25", + "localTime": "10:20:30", +} +SIMPLIFY_RECEIPT = { + "success": True, + "issuedAt": "2026-08-25 10:20:30", + "verificationUrl": "https://verify.tra.go.tz/SIM123_102030", + "verificationCode": "SIM123", + "invoiceId": "inv-1", +} + + +def posting_doc(invoice): + invoice.reload() + return frappe.get_doc("VFD Provider Posting", invoice.vfd_posting_info) + + +def set_setting(doctype, fieldname, value): + frappe.db.set_value(doctype, COMPANY, fieldname, value) + frappe.clear_document_cache(doctype, COMPANY) + + +class TestGetVatAmount(UnitTestCase): + def test_exclusive_standard_rate_adds_vat(self): + item = frappe._dict(base_net_amount=100, base_amount=100) + self.assertEqual(get_vat_amount(item, "A", precision=2), 118.0) + self.assertEqual(get_vat_amount(item, "1"), 118.0) + + def test_inclusive_and_non_standard_use_base_amount(self): + inclusive = frappe._dict(base_net_amount=84.75, base_amount=100) + self.assertEqual(get_vat_amount(inclusive, "A", precision=2), 100) + self.assertEqual(get_vat_amount(inclusive, "A"), 100) + exempt = frappe._dict(base_net_amount=100, base_amount=100) + self.assertEqual(get_vat_amount(exempt, "E", precision=2), 100) + self.assertEqual(get_vat_amount(exempt, "E"), 100) + + def test_distributed_discount_counts_as_exclusive(self): + item = frappe._dict(base_net_amount=90, base_amount=100, distributed_discount_amount=10) + self.assertEqual(get_vat_amount(item, "A", precision=2), 118.0) + + +class TestVFDPlusProvider(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_vfd_records() + set_company_provider("VFDPlus") + + def test_settings_validate_loads_serial_info(self): + settings = frappe.get_doc("VFDPlus Settings", COMPANY) + self.assertEqual(settings.serial_id, "SER-1") + self.assertEqual(settings.serial_code, "CRED-1") + self.assertEqual(settings.vat_enabled, 1) + self.assertIn("SER-1", settings.response) + body = dict(SERIAL_INFO, msg_data=dict(SERIAL_INFO["msg_data"], tin="555")) + with patch(VFDPLUS_REQUEST, return_value=fake_response(body)) as request: + settings.save() + self.assertEqual(settings.tin, "555") + self.assertIn("555", settings.response) + kwargs = request.call_args.kwargs + self.assertEqual(kwargs["method"], "GET") + self.assertEqual(kwargs["url"], "https://vfdplus.test/api/serial") + self.assertEqual(kwargs["headers"]["VFDPLUS-API-KEY"], "plus-key") + + def test_get_serial_info_saves_outside_validate(self): + settings = frappe.get_doc("VFDPlus Settings", COMPANY) + body = dict(SERIAL_INFO, msg_data=dict(SERIAL_INFO["msg_data"], vrn="VRN-9")) + with patch(VFDPLUS_REQUEST, return_value=fake_response(body)): + vfdplus.get_serial_info(settings, method="on_update") + self.assertEqual(frappe.db.get_value("VFDPlus Settings", COMPANY, "vrn"), "VRN-9") + + def test_get_account_info(self): + body = {"msg_status": "OK", "msg_code": 2000, "msg_data": {"account_id": "acc-1"}} + with patch(VFDPLUS_REQUEST, return_value=fake_response(body)) as request: + self.assertEqual(vfdplus.get_account_info(COMPANY), body) + self.assertEqual(request.call_args.kwargs["url"], "https://vfdplus.test/api/account") + + def test_get_payload(self): + invoice = make_sales_invoice(submit=True) + payload = vfdplus.get_payload(invoice) + self.assertEqual(payload["credential_code"], "CRED-1") + self.assertEqual(payload["trans_no"], invoice.name) + self.assertEqual(payload["customer_info"]["cust_id"], "123456789") + self.assertEqual(payload["customer_info"]["cust_id_type"], "1- TIN") + self.assertEqual(payload["payment_methods"], [{"pmt_type": "INVOICE", "pmt_amount": 236.0}]) + self.assertEqual(payload["cart_totals"]["total_amount"], 236.0) + self.assertEqual(payload["cart_totals"]["item_counts"], 1) + item = payload["cart_items"][0] + self.assertEqual(item["vat_rate_code"], "A") + self.assertEqual(item["vat_rate_id"], "1") + self.assertEqual(item["item_qty"], 2) + self.assertEqual(item["sp"], 236.0) + self.assertEqual(item["usp"], 118.0) + self.assertEqual(payload["user_info"]["username"], "Administrator") + + def test_get_payload_exempt_item(self): + invoice = make_sales_invoice(item_tax_template=EXEMPT_TEMPLATE, submit=True) + payload = vfdplus.get_payload(invoice) + self.assertEqual(payload["cart_items"][0]["vat_rate_code"], "E") + self.assertEqual(payload["cart_items"][0]["sp"], 200.0) + + def test_autogenerate_on_submit_posts_receipt(self): + with patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)) as request: + invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True) + request.assert_called_once() + kwargs = request.call_args.kwargs + self.assertEqual(kwargs["method"], "POST") + self.assertEqual(kwargs["url"], "https://vfdplus.test/api/receipt") + self.assertEqual(json.loads(kwargs["data"])["trans_no"], invoice.name) + + invoice.reload() + self.assertEqual(invoice.vfd_status, "Success") + self.assertEqual(invoice.vfd_rctvnum, "ABC123") + self.assertEqual(invoice.vfd_verification_url, "https://verify.tra.go.tz/ABC123_102030") + self.assertEqual(str(invoice.vfd_date), "2026-08-25") + self.assertEqual(str(invoice.vfd_time), "10:20:30") + + posting = posting_doc(invoice) + self.assertEqual(posting.sales_invoice, invoice.name) + self.assertEqual(posting.ackcode, 2000) + self.assertIn("ABC123", posting.ackmsg) + self.assertIn(invoice.name, posting.req_data) + self.assertIn("plus-key", posting.req_headers) + + def test_generate_tra_vfd_post_updates_database(self): + invoice = make_sales_invoice(submit=True) + self.assertEqual(invoice.vfd_status, "Not Sent") + with ( + patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)), + patch.object(frappe.db, "commit"), + ): + result = vfd_utils.generate_tra_vfd(invoice.name) + self.assertEqual(result["vfd_provider"], "VFDPlus") + self.assertFalse(result["preview"]) + invoice.reload() + self.assertEqual(invoice.vfd_status, "Success") + self.assertEqual(invoice.vfd_rctvnum, "ABC123") + self.assertEqual(posting_doc(invoice).ackcode, 2000) + + def test_generate_tra_vfd_preview_returns_payload(self): + set_setting("VFDPlus Settings", "enable_vfd_preview", 1) + self.addCleanup(set_setting, "VFDPlus Settings", "enable_vfd_preview", 0) + invoice = make_sales_invoice(submit=True) + with patch(VFDPLUS_REQUEST) as request: + result = vfd_utils.generate_tra_vfd(invoice.name) + request.assert_not_called() + self.assertTrue(result["preview"]) + self.assertEqual(result["data"]["trans_no"], invoice.name) + with ( + patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)), + patch.object(frappe.db, "commit"), + ): + result = vfd_utils.generate_tra_vfd(invoice.name, caller="Scheduler") + self.assertFalse(result["preview"]) + + def test_generate_tra_vfd_skips_non_vfd_invoices(self): + invoice = make_sales_invoice( + is_not_vfd_invoice=1, item_tax_template=None, with_taxes=False, submit=True + ) + self.assertIsNone(vfd_utils.generate_tra_vfd(invoice.name)) + invoice = make_sales_invoice(submit=True) + invoice.db_set("vfd_status", "Success") + self.assertIsNone(vfd_utils.generate_tra_vfd(invoice.name)) + + def test_generate_tra_vfd_checks_start_date(self): + invoice = make_sales_invoice(submit=True) + set_setting("VFDPlus Settings", "vfd_start_date", None) + self.addCleanup(set_setting, "VFDPlus Settings", "vfd_start_date", add_days(nowdate(), -30)) + self.assertRaisesRegex( + frappe.ValidationError, "VFD Start Date", vfd_utils.generate_tra_vfd, invoice.name + ) + set_setting("VFDPlus Settings", "vfd_start_date", add_days(nowdate(), 1)) + self.assertRaisesRegex( + frappe.ValidationError, "cannot be generated", vfd_utils.generate_tra_vfd, invoice.name + ) + + def test_unsupported_provider_throws(self): + frappe.get_doc( + { + "doctype": "VFD Provider", + "vfd_provider": "OtherVFD", + "vfd_provider_settings": "VFDPlus Settings", + } + ).insert() + set_company_provider("OtherVFD") + self.addCleanup(set_company_provider, "VFDPlus") + invoice = make_sales_invoice(submit=True) + self.assertRaisesRegex( + frappe.ValidationError, "not supported", vfd_utils.generate_tra_vfd, invoice.name + ) + set_setting("VFDPlus Settings", "enable_vfd_preview", 1) + self.addCleanup(set_setting, "VFDPlus Settings", "enable_vfd_preview", 0) + self.assertRaisesRegex( + frappe.ValidationError, "not supported", vfd_utils.generate_tra_vfd, invoice.name + ) + + def test_api_error_status_throws_after_retries(self): + invoice = make_sales_invoice(submit=True) + body = {"msg_status": "ERROR", "msg_code": 4001, "msg_data": {}} + with ( + patch(VFDPLUS_REQUEST, return_value=fake_response(body)) as request, + patch(VFDPLUS_SLEEP), + patch.object(frappe, "log_error") as log_error, + ): + self.assertRaisesRegex( + frappe.ValidationError, "Connection failure", vfdplus.post_fiscal_receipt, invoice + ) + self.assertEqual(request.call_count, 3) + self.assertIn("Error returned from VFDPlus: 4001", log_error.call_args.kwargs["message"]) + self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "vfd_status"), "Not Sent") + + def test_http_error_throws(self): + invoice = make_sales_invoice(submit=True) + response = fake_response({"detail": "bad"}, status_code=500, ok=False) + with patch(VFDPLUS_REQUEST, return_value=response), patch(VFDPLUS_SLEEP): + self.assertRaisesRegex( + frappe.ValidationError, + "Connection failure", + vfdplus.post_fiscal_receipt, + invoice_id=invoice.name, + ) + + def test_warning_already_posted_is_accepted(self): + invoice = make_sales_invoice(submit=True) + body = dict(PLUS_RECEIPT, msg_status="WARNING", msg_code=4015) + with patch(VFDPLUS_REQUEST, return_value=fake_response(body)), patch.object(frappe.db, "commit"): + result = vfdplus.post_fiscal_receipt(invoice_id=invoice.name, method="POST") + self.assertEqual(result["data"]["msg_code"], 4015) + self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "vfd_rctvnum"), "ABC123") + + def test_post_fiscal_receipt_requires_invoice(self): + self.assertRaisesRegex( + frappe.ValidationError, "Sales Invoice is required", vfdplus.post_fiscal_receipt + ) + self.assertRaisesRegex( + frappe.ValidationError, "Sales Invoice is required", total_vfd.post_fiscal_receipt + ) + self.assertRaisesRegex( + frappe.ValidationError, "Sales Invoice is required", simplify.post_fiscal_receipt + ) + + +class TestTotalVFDProvider(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_vfd_records() + set_company_provider("TotalVFD") + + def test_get_payload(self): + invoice = make_sales_invoice( + items=[ + {"item_code": ITEM, "qty": 2, "rate": 100}, + {"item_code": SECOND_ITEM, "qty": 1, "rate": 50}, + ], + submit=True, + ) + payload = total_vfd.get_payload(invoice) + self.assertEqual(payload["serial"], "TOTAL-SERIAL") + self.assertEqual(payload["referenceNumber"], invoice.name) + self.assertEqual(payload["customer"]["idType"], "1") + self.assertEqual(payload["customer"]["idValue"], "123456789") + self.assertEqual(payload["payments"], [{"type": "invoice", "amount": 295.0}]) + self.assertEqual(len(payload["items"]), 2) + self.assertEqual(payload["items"][0]["price"], 236.0) + self.assertEqual(payload["items"][0]["vatGroup"], "A") + self.assertEqual(payload["items"][1]["price"], 59.0) + + def test_get_payload_grouped_by_vat(self): + set_setting("Total VFD Setting", "is_vat_grouped", 1) + self.addCleanup(set_setting, "Total VFD Setting", "is_vat_grouped", 0) + invoice = make_sales_invoice( + items=[ + {"item_code": ITEM, "qty": 2, "rate": 100}, + {"item_code": SECOND_ITEM, "qty": 1, "rate": 50}, + ], + submit=True, + ) + payload = total_vfd.get_payload(invoice) + self.assertEqual(len(payload["items"]), 1) + self.assertEqual(payload["items"][0]["id"], "Items in VAT Group A") + self.assertEqual(payload["items"][0]["price"], 295.0) + self.assertEqual(payload["items"][0]["qty"], 1) + + def test_autogenerate_on_submit_posts_receipt(self): + with patch(TOTAL_REQUEST, return_value=fake_response(TOTAL_RECEIPT)) as request: + invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True) + kwargs = request.call_args.kwargs + self.assertEqual(kwargs["url"], "https://totalvfd.test/sales") + self.assertEqual(kwargs["headers"]["Authorization"], "Bearer total-token") + self.assertEqual(kwargs["headers"]["x-active-business"], "business-1") + self.assertEqual(json.loads(kwargs["data"])["referenceNumber"], invoice.name) + + invoice.reload() + self.assertEqual(invoice.vfd_status, "Success") + self.assertEqual(invoice.vfd_rctvnum, "778899") + self.assertEqual(invoice.vfd_verification_url, TOTAL_RECEIPT["verificationLink"]) + self.assertEqual(str(invoice.vfd_date), "2026-08-25") + posting = posting_doc(invoice) + self.assertEqual(posting.ackcode, 200) + self.assertEqual(posting.sales_invoice, invoice.name) + self.assertIn("778899", posting.ackmsg) + self.assertIn("total-token", posting.req_headers) + + def test_generate_tra_vfd_post_updates_database(self): + invoice = make_sales_invoice(submit=True) + with ( + patch(TOTAL_REQUEST, return_value=fake_response(TOTAL_RECEIPT)), + patch.object(frappe.db, "commit"), + ): + result = vfd_utils.generate_tra_vfd(invoice.name) + self.assertEqual(result["vfd_provider"], "TotalVFD") + invoice.reload() + self.assertEqual(invoice.vfd_status, "Success") + self.assertEqual(invoice.vfd_rctvnum, "778899") + self.assertEqual(posting_doc(invoice).ackcode, 200) + + def test_conflict_response_uses_data_key(self): + invoice = make_sales_invoice(submit=True) + response = fake_response({"data": TOTAL_RECEIPT}, status_code=409, ok=False) + with patch(TOTAL_REQUEST, return_value=response), patch.object(frappe.db, "commit"): + result = total_vfd.post_fiscal_receipt(invoice_id=invoice.name) + self.assertEqual(result["data"]["rctvnum"], "778899") + + def test_http_error_throws_after_retries(self): + invoice = make_sales_invoice(submit=True) + response = fake_response({"error": "down"}, status_code=500, ok=False) + with patch(TOTAL_REQUEST, return_value=response) as request, patch(TOTAL_SLEEP): + self.assertRaisesRegex( + frappe.ValidationError, "Connection failure", total_vfd.post_fiscal_receipt, invoice + ) + self.assertEqual(request.call_count, 3) + + +class TestSimplifyVFDProvider(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_vfd_records() + set_company_provider("SimplifyVFD") + + def settings(self): + frappe.clear_document_cache("Simplify VFD Settings", COMPANY) + return frappe.get_doc("Simplify VFD Settings", COMPANY) + + def test_get_bearer_token(self): + settings = self.settings() + body = {"token": "fresh-token", "refresh_token": "fresh-refresh"} + with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request: + self.assertTrue(settings.get_bearer_token()) + kwargs = request.call_args.kwargs + self.assertEqual(kwargs["url"], "https://simplify.test/login") + self.assertEqual( + json.loads(kwargs["data"]), {"username": "simplify-user", "password": "simplify-pass"} + ) + self.assertNotIn("Authorization", kwargs["headers"]) + self.assertEqual(settings.get_password("bearer_token"), "fresh-token") + self.assertEqual(settings.get_password("refresh_token"), "fresh-refresh") + self.assertGreater(get_datetime(settings.token_expires), now_datetime()) + + def test_get_bearer_token_rejects_bad_credentials(self): + settings = self.settings() + with patch(SIMPLIFY_REQUEST, return_value=fake_response({"message": "denied"})): + self.assertRaisesRegex(frappe.ValidationError, "Invalid username", settings.get_bearer_token) + settings.username = "" + self.assertRaisesRegex(frappe.ValidationError, "Username and Password", settings.get_bearer_token) + + def test_refresh_bearer_token(self): + settings = self.settings() + stored_refresh_token = settings.get_password("refresh_token") + body = {"token": "refreshed", "refresh_token": "refreshed-r"} + with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request: + self.assertTrue(settings.refresh_bearer_token()) + self.assertEqual(request.call_args.kwargs["url"], "https://simplify.test/refresh") + self.assertEqual( + json.loads(request.call_args.kwargs["data"]), {"refresh_token": stored_refresh_token} + ) + self.assertEqual(settings.get_password("bearer_token"), "refreshed") + with patch(SIMPLIFY_REQUEST, return_value=fake_response({"token": "x"})): + self.assertRaisesRegex( + frappe.ValidationError, "Invalid refresh token", settings.refresh_bearer_token + ) + + def test_refresh_requires_stored_refresh_token(self): + settings = self.settings() + settings.refresh_token = None + self.assertRaisesRegex( + frappe.ValidationError, "Refresh Token is not found", settings.refresh_bearer_token + ) + + def test_get_access_token_refreshes_expired_tokens(self): + set_setting("Simplify VFD Settings", "token_expires", add_to_date(now_datetime(), minutes=-1)) + body = {"token": "sched-token", "refresh_token": "sched-refresh"} + with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request: + simplify.get_access_token() + self.assertEqual(request.call_args.kwargs["url"], "https://simplify.test/refresh") + self.assertEqual(self.settings().get_password("bearer_token"), "sched-token") + with patch(SIMPLIFY_REQUEST) as request: + simplify.get_access_token() + request.assert_not_called() + + def test_get_refresh_token_logs_in_again(self): + body = {"token": "login-token", "refresh_token": "login-refresh"} + with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)) as request: + simplify.get_refresh_token() + self.assertEqual(request.call_args.kwargs["url"], "https://simplify.test/login") + self.assertEqual(self.settings().get_password("refresh_token"), "login-refresh") + + def test_get_payload(self): + invoice = make_sales_invoice(submit=True) + payload = simplify.get_payload(invoice) + self.assertEqual(payload["partnerInvoiceId"], invoice.name) + self.assertEqual(payload["invoiceAmountType"], "INCLUSIVE") + self.assertEqual(payload["customer"]["identificationType"], "TAX_IDENTIFICATION_NUMBER") + self.assertEqual(payload["customer"]["identificationNumber"], "123456789") + self.assertEqual(payload["customer"]["name"], invoice.customer_name) + self.assertEqual(payload["payments"], [{"type": "INVOICE", "amount": 236.0}]) + item = payload["items"][0] + self.assertEqual(item["description"], ITEM) + self.assertEqual(item["quantity"], 2) + self.assertEqual(item["unitAmount"], 118.0) + self.assertEqual(item["taxType"], "STANDARD") + + def test_get_payload_without_customer_id(self): + invoice = make_sales_invoice(submit=True) + invoice.vfd_cust_id_type = "6- Other" + invoice.vfd_cust_id = "999999999" + payload = simplify.get_payload(invoice) + self.assertEqual(payload["customer"]["identificationType"], "NO_IDENTIFICATION") + self.assertEqual(payload["customer"]["identificationNumber"], "") + + def test_autogenerate_on_submit_posts_receipt(self): + with patch(SIMPLIFY_REQUEST, return_value=fake_response(SIMPLIFY_RECEIPT)) as request: + invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True) + kwargs = request.call_args.kwargs + self.assertEqual(kwargs["url"], "https://simplify.test/invoice") + self.assertEqual(kwargs["headers"]["Authorization"], "Bearer simplify-token") + self.assertEqual(json.loads(kwargs["data"])["partnerInvoiceId"], invoice.name) + + invoice.reload() + self.assertEqual(invoice.vfd_status, "Success") + self.assertEqual(invoice.vfd_rctvnum, "SIM123") + self.assertEqual(invoice.vfd_verification_url, SIMPLIFY_RECEIPT["verificationUrl"]) + self.assertEqual(str(invoice.vfd_date), "2026-08-25") + self.assertEqual(str(invoice.vfd_time), "10:20:30") + posting = posting_doc(invoice) + self.assertEqual(posting.ackcode, 200) + self.assertIn("SIM123", posting.ackmsg) + self.assertIn(invoice.name, posting.req_data) + self.assertTrue( + frappe.db.exists("Comment", {"reference_name": invoice.name, "content": "VFD Invoice ID: inv-1"}) + ) + + def test_failed_response_marks_invoice_failed(self): + body = {"success": False, "message": "rejected"} + with patch(SIMPLIFY_REQUEST, return_value=fake_response(body)): + invoice = make_sales_invoice(is_auto_generate_vfd=1, submit=True) + invoice.reload() + self.assertEqual(invoice.vfd_status, "Failed") + self.assertFalse(invoice.vfd_rctvnum) + self.assertEqual(str(posting_doc(invoice).date), nowdate()) + + def test_generate_tra_vfd_post_updates_database(self): + invoice = make_sales_invoice(submit=True) + with ( + patch(SIMPLIFY_REQUEST, return_value=fake_response(SIMPLIFY_RECEIPT)), + patch.object(frappe.db, "commit"), + ): + result = vfd_utils.generate_tra_vfd(invoice.name) + self.assertEqual(result["vfd_provider"], "SimplifyVFD") + invoice.reload() + self.assertEqual(invoice.vfd_status, "Success") + self.assertEqual(invoice.vfd_rctvnum, "SIM123") + + def test_expired_token_is_refreshed_before_posting(self): + set_setting("Simplify VFD Settings", "token_expires", add_to_date(now_datetime(), minutes=-1)) + invoice = make_sales_invoice(submit=True) + responses = [ + fake_response({"token": "renewed", "refresh_token": "renewed-r"}), + fake_response(SIMPLIFY_RECEIPT), + ] + with patch(SIMPLIFY_REQUEST, side_effect=responses) as request, patch.object(frappe.db, "commit"): + simplify.post_fiscal_receipt(invoice_id=invoice.name) + self.assertEqual(request.call_count, 2) + self.assertEqual(request.call_args_list[0].kwargs["url"], "https://simplify.test/refresh") + self.assertEqual(request.call_args_list[1].kwargs["headers"]["Authorization"], "Bearer renewed") + + def test_http_error_throws_after_retries(self): + invoice = make_sales_invoice(submit=True) + response = fake_response({"error": "down"}, status_code=500, ok=False) + with patch(SIMPLIFY_REQUEST, return_value=response) as request, patch(SIMPLIFY_SLEEP): + self.assertRaisesRegex(frappe.ValidationError, "Error is", simplify.post_fiscal_receipt, invoice) + self.assertEqual(request.call_count, 3) + + +class TestPostingAllVFDInvoices(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_vfd_records() + set_company_provider("VFDPlus") + + def tearDown(self): + frappe.local.flags.vfd_posting = False + + def test_posts_pending_invoices(self): + invoice = make_sales_invoice(submit=True) + invoice.db_set("vfd_status", "Pending") + with ( + patch(VFDPLUS_REQUEST, return_value=fake_response(PLUS_RECEIPT)) as request, + patch.object(frappe.db, "commit"), + ): + vfd_utils.posting_all_vfd_invoices() + self.assertEqual(json.loads(request.call_args.kwargs["data"])["trans_no"], invoice.name) + invoice.reload() + self.assertEqual(invoice.vfd_status, "Success") + self.assertEqual(invoice.vfd_rctvnum, "ABC123") + self.assertFalse(frappe.local.flags.vfd_posting) + + def test_skips_sent_and_non_vfd_invoices(self): + make_sales_invoice(submit=True) + with patch(VFDPLUS_REQUEST) as request: + vfd_utils.posting_all_vfd_invoices() + request.assert_not_called() + + def test_flag_guard_prevents_parallel_runs(self): + frappe.local.flags.vfd_posting = True + with patch(VFDPLUS_REQUEST) as request, patch.object(frappe, "log_error") as log_error: + vfd_utils.posting_all_vfd_invoices() + request.assert_not_called() + log_error.assert_called_once() + + def test_posts_with_other_providers(self): + for provider, request_path, body in ( + ("TotalVFD", TOTAL_REQUEST, TOTAL_RECEIPT), + ("SimplifyVFD", SIMPLIFY_REQUEST, SIMPLIFY_RECEIPT), + ): + set_company_provider(provider) + self.addCleanup(set_company_provider, "VFDPlus") + invoice = make_sales_invoice(submit=True) + invoice.db_set("vfd_status", "Failed") + with patch(request_path, return_value=fake_response(body)), patch.object(frappe.db, "commit"): + vfd_utils.posting_all_vfd_invoices() + self.assertEqual(frappe.db.get_value("Sales Invoice", invoice.name, "vfd_status"), "Success") + + def test_provider_records(self): + for name, info in PROVIDERS.items(): + provider = frappe.get_doc("VFD Provider", name) + self.assertEqual(provider.vfd_provider_settings, info["settings"]) + self.assertEqual({row.key: row.value for row in provider.attributes}, info["attributes"]) From c579b9556dc512c802ff6be602d027c45e4ea6be Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:28 +0300 Subject: [PATCH 64/74] test: cover fiscal validation on sales invoices --- csf_tz/tests/test_vfd_sales_invoice.py | 169 +++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 csf_tz/tests/test_vfd_sales_invoice.py diff --git a/csf_tz/tests/test_vfd_sales_invoice.py b/csf_tz/tests/test_vfd_sales_invoice.py new file mode 100644 index 00000000..7f20c9a0 --- /dev/null +++ b/csf_tz/tests/test_vfd_sales_invoice.py @@ -0,0 +1,169 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz.tests.vfd_test_records import ( + EXEMPT_TEMPLATE, + ITEM, + NO_CODE_TEMPLATE, + STANDARD_OTHER_TEMPLATE, + STANDARD_TEMPLATE, + STANDARD_ZERO_TEMPLATE, + make_customer, + make_item_tax_template, + make_item_tax_templates, + make_sales_invoice, + make_vfd_providers, + make_vfdplus_settings, +) +from csf_tz.vfd_support import sales_invoice as vfd + + +class TestVFDCustomer(IntegrationTestCase): + def test_validate_cleans_tax_id_into_vfd_fields(self): + customer = make_customer("_Test VFD TIN Customer", tax_id="123-456-789") + self.assertEqual(customer.tax_id, "123456789") + self.assertEqual(customer.vfd_cust_id_type, "1- TIN") + self.assertEqual(customer.vfd_cust_id, "123456789") + + def test_validate_without_tax_id_uses_other_id_type(self): + customer = make_customer("_Test VFD Other Customer", tax_id="") + self.assertEqual(customer.tax_id, "") + self.assertEqual(customer.vfd_cust_id_type, "6- Other") + self.assertEqual(customer.vfd_cust_id, "999999999") + + +class TestVFDSalesInvoiceValidation(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_item_tax_templates() + make_customer() + + def test_submit_sets_customer_id_from_customer(self): + invoice = make_sales_invoice(submit=True) + self.assertEqual(invoice.docstatus, 1) + self.assertEqual(invoice.vfd_cust_id, "123456789") + self.assertEqual(invoice.vfd_cust_id_type, "1- TIN") + self.assertEqual(invoice.vfd_status, "Not Sent") + self.assertEqual(invoice.base_total_taxes_and_charges, 36) + + def test_exempt_item_with_zero_tax_submits(self): + invoice = make_sales_invoice(item_tax_template=EXEMPT_TEMPLATE, submit=True) + self.assertEqual(invoice.base_total_taxes_and_charges, 0) + + def test_non_vfd_invoice_skips_validation(self): + invoice = make_sales_invoice( + item_tax_template=None, with_taxes=False, is_not_vfd_invoice=1, submit=True + ) + self.assertEqual(invoice.docstatus, 1) + + def test_throws_when_net_total_is_zero(self): + invoice = make_sales_invoice(items=[{"item_code": ITEM, "qty": 1, "discount_percentage": 100}]) + self.assertEqual(invoice.base_net_total, 0) + self.assertRaisesRegex(frappe.ValidationError, "Base net amount is zero", invoice.submit) + + def test_throws_when_taxes_missing(self): + invoice = make_sales_invoice(item_tax_template=None, with_taxes=False) + self.assertRaisesRegex(frappe.ValidationError, "Taxes not set correctly", invoice.submit) + + def test_throws_without_item_tax_template(self): + invoice = make_sales_invoice(item_tax_template=None) + self.assertRaisesRegex(frappe.ValidationError, "Item Taxes Template not set", invoice.submit) + + def test_uses_item_default_tax_template(self): + frappe.db.set_value("Item", ITEM, "default_tax_template", STANDARD_TEMPLATE) + self.addCleanup(frappe.db.set_value, "Item", ITEM, "default_tax_template", None) + invoice = make_sales_invoice(item_tax_template=None, submit=True) + self.assertEqual(invoice.items[0].item_tax_template, STANDARD_TEMPLATE) + + def test_throws_for_standard_item_without_18pct_tax(self): + invoice = make_sales_invoice(item_tax_template=STANDARD_ZERO_TEMPLATE) + self.assertRaisesRegex(frappe.ValidationError, "Standard Rate item", invoice.submit) + + def test_throws_for_other_tax_rate(self): + invoice = make_sales_invoice(item_tax_template=STANDARD_OTHER_TEMPLATE) + self.assertRaisesRegex(frappe.ValidationError, "Other Tax item", invoice.submit) + + def test_throws_for_exempt_item_with_18pct_tax(self): + template = make_item_tax_template("VFD Exempt Wrong - _TC", "5- Exempt", 18) + invoice = make_sales_invoice(item_tax_template=template) + self.assertRaisesRegex(frappe.ValidationError, "Non Standard Rate item", invoice.submit) + + def test_throws_when_template_has_no_vfd_taxcode(self): + invoice = make_sales_invoice(item_tax_template=NO_CODE_TEMPLATE) + self.assertRaisesRegex(frappe.ValidationError, "VFD Tax Code not setup", invoice.submit) + + def test_vat_enabled_vfdplus_only_warns_for_standard_item(self): + make_vfd_providers() + make_vfdplus_settings() + frappe.db.set_value("VFDPlus Settings", "_Test Company", "vat_enabled", 1) + invoice = make_sales_invoice(item_tax_template=STANDARD_ZERO_TEMPLATE, submit=True) + self.assertEqual(invoice.docstatus, 1) + + def test_throws_when_customer_id_type_missing(self): + customer = make_customer("_Test VFD No Type Customer") + frappe.db.set_value("Customer", customer.name, "vfd_cust_id_type", "") + invoice = make_sales_invoice(customer=customer.name) + invoice.vfd_cust_id_type = "" + self.assertRaisesRegex(frappe.ValidationError, "VFD Customer ID Type", invoice.submit) + + def test_get_customer_id_info_for_customer_without_id(self): + customer = make_customer("_Test VFD No ID Customer") + frappe.db.set_value("Customer", customer.name, {"vfd_cust_id": "", "vfd_cust_id_type": ""}) + info = vfd.get_customer_id_info(customer.name) + self.assertEqual(info, {"cust_id": "", "cust_id_type": 6, "mobile_no": "2550712345678"}) + + +class TestVFDSalesInvoiceCancel(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + make_item_tax_templates() + make_customer() + + def test_cancel_blocked_after_vfd_sent(self): + invoice = make_sales_invoice(submit=True) + invoice.db_set("vfd_rctvnum", "ABC123") + invoice.reload() + self.assertRaisesRegex(frappe.ValidationError, "already sent to TRA", invoice.cancel) + + def test_cancel_allowed_before_vfd_sent(self): + invoice = make_sales_invoice(submit=True) + invoice.cancel() + self.assertEqual(invoice.docstatus, 2) + + +class TestVFDSalesInvoiceHelpers(IntegrationTestCase): + def test_get_item_taxcode_throws_without_template(self): + self.assertRaisesRegex(frappe.ValidationError, "Item Taxes Template not set$", vfd.get_item_taxcode) + self.assertRaisesRegex(frappe.ValidationError, "for item X$", vfd.get_item_taxcode, item_code="X") + self.assertRaisesRegex( + frappe.ValidationError, + "for item X in invoice INV", + vfd.get_item_taxcode, + item_code="X", + invoice_name="INV", + ) + + def test_get_item_taxcode_reads_template(self): + make_item_tax_templates() + self.assertEqual(vfd.get_item_taxcode(STANDARD_TEMPLATE), 1) + self.assertEqual(vfd.get_item_taxcode(EXEMPT_TEMPLATE), 5) + + def test_get_item_inclusive_amount(self): + exclusive = frappe._dict(base_net_amount=100, base_amount=100, item_tax_rate='{"VAT": 18}') + self.assertEqual(vfd.get_item_inclusive_amount(exclusive), 118) + zero_rate = frappe._dict(base_net_amount=100, base_amount=100, item_tax_rate='{"VAT": 0}') + self.assertEqual(vfd.get_item_inclusive_amount(zero_rate), 100) + no_rate = frappe._dict(base_net_amount=100, base_amount=100, item_tax_rate="{}") + self.assertEqual(vfd.get_item_inclusive_amount(no_rate), 100) + inclusive = frappe._dict(base_net_amount=84.75, base_amount=100, item_tax_rate='{"VAT": 18}') + self.assertEqual(vfd.get_item_inclusive_amount(inclusive), 100) + + def test_text_cleaners(self): + self.assertEqual(vfd.remove_special_characters("A-1 (b)!"), "A1 b") + self.assertEqual(vfd.remove_all_except_numbers("+255 (0)71"), "255071") + self.assertEqual(vfd.remove_all_except_numbers(None), "") + + def test_tax_breakup_is_empty_without_taxes(self): + self.assertIsNone(vfd.get_itemised_tax_breakup_html(frappe._dict(taxes=[]))) From 3849c4672a5261926d959ea4794fb6cb8e30278f Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:39 +0300 Subject: [PATCH 65/74] test: cover the VFD settings and their patches --- csf_tz/tests/test_vfd_settings_patches.py | 139 ++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 csf_tz/tests/test_vfd_settings_patches.py diff --git a/csf_tz/tests/test_vfd_settings_patches.py b/csf_tz/tests/test_vfd_settings_patches.py new file mode 100644 index 00000000..1fc84791 --- /dev/null +++ b/csf_tz/tests/test_vfd_settings_patches.py @@ -0,0 +1,139 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from csf_tz.csf_tz.doctype.csf_api_response_log.csf_api_response_log import add_log +from csf_tz.patches import migrate_vfd_providers_to_csf_tz +from csf_tz.patches.custom_fields import vfd_providers_updated_custom_fields + +VFD_FIELDS = [ + ("Customer", "vfd_cust_id"), + ("Customer", "vfd_cust_id_type"), + ("Sales Invoice", "vfd_rctvnum"), + ("Sales Invoice", "vfd_status"), + ("Sales Invoice", "is_auto_generate_vfd"), + ("Item Tax Template", "vfd_taxcode"), + ("Mode of Payment", "vfd_pmttype"), +] + + +def custom_field_count(): + return frappe.db.count("Custom Field", {"fieldname": ["like", "vfd_%"]}) + + +class TestVFDPatches(IntegrationTestCase): + def test_migrate_vfd_providers_sets_app_name(self): + for module in migrate_vfd_providers_to_csf_tz.MODULES: + frappe.db.set_value("Module Def", module, "app_name", "erpnext", update_modified=False) + migrate_vfd_providers_to_csf_tz.execute() + migrate_vfd_providers_to_csf_tz.execute() + for module in migrate_vfd_providers_to_csf_tz.MODULES: + self.assertEqual(frappe.db.get_value("Module Def", module, "app_name"), "csf_tz") + + def test_vfd_custom_fields_patch_is_idempotent(self): + vfd_providers_updated_custom_fields.execute() + count = custom_field_count() + vfd_providers_updated_custom_fields.execute() + self.assertEqual(custom_field_count(), count) + for doctype, fieldname in VFD_FIELDS: + self.assertTrue( + frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": fieldname}), fieldname + ) + options = frappe.db.get_value( + "Custom Field", {"dt": "Sales Invoice", "fieldname": "vfd_status"}, "options" + ) + self.assertEqual(options.split("\n"), ["Not Sent", "Pending", "Failed", "Success"]) + + +class TestCSFAPIResponseLog(IntegrationTestCase): + def test_add_log_stores_request_and_response(self): + with patch.object(frappe.db, "commit"): + name = add_log("POST", "https://api.test/x", {"h": 1}, {"b": 2}, {"r": 3}, 201) + log = frappe.get_doc("CSF API Response Log", name) + self.assertEqual(log.request_type, "POST") + self.assertEqual(log.request_url, "https://api.test/x") + self.assertEqual(log.request_header, "{'h': 1}") + self.assertEqual(log.request_body, "{'b': 2}") + self.assertEqual(log.response_data, "{'r': 3}") + self.assertEqual(log.status_code, "201") + self.assertEqual(log.user_id, "Administrator") + + +class TestCSFTZSettings(IntegrationTestCase): + def settings(self): + return frappe.get_single("CSF TZ Settings") + + def reset_populate_flag(self): + frappe.db.set_single_value("CSF TZ Settings", "populate_tz_regions", 0) + + def realtime_message(self, publish): + return next( + c.kwargs["message"] for c in publish.call_args_list if c.kwargs.get("event") == "msgprint" + ) + + def test_working_days_must_be_in_range(self): + for days in (31, 0): + settings = self.settings() + settings.enable_fixed_working_days_per_month = 1 + settings.working_days_per_month = days + self.assertRaisesRegex(frappe.ValidationError, "between 1 and 30", settings.save) + settings = self.settings() + settings.enable_fixed_working_days_per_month = 1 + settings.working_days_per_month = 26 + settings.save() + + def test_email_queue_batch_size_updates_site_config(self): + settings = self.settings() + settings.override_email_queue_batch_size = 1 + settings.email_qatch_batch_size = 75 + with patch("csf_tz.csf_tz.doctype.csf_tz_settings.csf_tz_settings.update_site_config") as update: + settings.save() + update.assert_called_once_with("email_queue_batch_size", 75) + + def test_populate_tz_regions_enqueues_background_job(self): + self.addCleanup(self.reset_populate_flag) + settings = self.settings() + settings.populate_tz_regions = 1 + with patch.object(frappe, "enqueue") as enqueue: + settings.save() + self.assertEqual(enqueue.call_args.kwargs["method"].__name__, "populate_tz_regions_background") + self.assertEqual(enqueue.call_args.kwargs["queue"], "long") + self.assertEqual(enqueue.call_args.kwargs["job_name"], "populate_tz_regions") + with patch.object(frappe, "enqueue") as enqueue: + settings.save() + enqueue.assert_not_called() + + def test_populate_tz_regions_resets_flag_when_enqueue_fails(self): + self.reset_populate_flag() + settings = self.settings() + settings.populate_tz_regions = 1 + with patch.object(frappe, "enqueue", side_effect=RuntimeError("no worker")): + settings.save() + self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "populate_tz_regions"), 0) + + def test_populate_tz_regions_background(self): + settings = self.settings() + with ( + patch("csf_tz.patches.tz_post_code.create_tz_post_code.execute") as execute, + patch.object(frappe.db, "commit"), + patch.object(frappe, "publish_realtime") as publish, + ): + settings.populate_tz_regions_background() + execute.assert_called_once() + self.assertIn("completed", self.realtime_message(publish)) + self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "tz_regions_populated"), 1) + self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "populate_tz_regions"), 0) + + def test_populate_tz_regions_background_failure(self): + settings = self.settings() + with ( + patch( + "csf_tz.patches.tz_post_code.create_tz_post_code.execute", side_effect=RuntimeError("boom") + ), + patch.object(frappe.db, "commit"), + patch.object(frappe, "publish_realtime") as publish, + ): + settings.populate_tz_regions_background() + self.assertIn("failed: boom", self.realtime_message(publish)) + self.assertEqual(frappe.db.get_single_value("CSF TZ Settings", "tz_regions_populated"), 0) From 6e42126c12c23dde71fd78fe116d4c31abcac1e7 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:44 +0300 Subject: [PATCH 66/74] test: cover TRA receipt verification and invoice creation --- csf_tz/tests/test_vfd_tra_tax_inv.py | 382 +++++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 csf_tz/tests/test_vfd_tra_tax_inv.py diff --git a/csf_tz/tests/test_vfd_tra_tax_inv.py b/csf_tz/tests/test_vfd_tra_tax_inv.py new file mode 100644 index 00000000..e325e02e --- /dev/null +++ b/csf_tz/tests/test_vfd_tra_tax_inv.py @@ -0,0 +1,382 @@ +from unittest.mock import MagicMock, patch + +import frappe +import requests +from bs4 import BeautifulSoup +from frappe.tests import IntegrationTestCase + +from csf_tz.csf_tz.doctype.tra_tax_inv import tra_tax_inv as tra +from csf_tz.tests.vfd_test_records import COMPANY, ITEM + +SESSION = "csf_tz.csf_tz.doctype.tra_tax_inv.tra_tax_inv.requests.Session" +CODE = "ABC123_102030" +FORM_HTML = '
' +RECEIPT_HTML = f"""TRA Receipt +

ACME LTD

+
+TIN: 123456789 +VRN: 40-123456-A +SERIAL NO: 10TZ100001 +UIN: UIN123 +TAX OFFICE: Ilala +MOBILE: 0712345678 +P.O.BOX 123 DAR +
+
+CUSTOMER NAME: John Doe +CUSTOMER ID TYPE: TIN +CUSTOMER ID: 987654321 +CUSTOMER MOBILE: 0755000000 +
+
+RECEIPT NO: RCT-001 +Z NUMBER: Z-77 +RECEIPT DATE: 25/08/2026 +RECEIPT TIME: 10:20:30 +
+ +
DescriptionQtyAmount
{ITEM}21,000.00
Unknown Widget1500.00
+ + + + + +
TOTAL EXCL OF TAX:2,118.64
TOTAL TAX:381.36
TOTAL INCL OF TAX:2,500.00
TAX RATE A (18%)381.36
+

RECEIPT VERIFICATION CODE

{CODE}

+ +""" + + +def html_response(text, url="https://verify.tra.go.tz/Verify/Verified"): + response = MagicMock() + response.text = text + response.status_code = 200 + response.url = url + response.headers = {} + response.cookies = {} + return response + + +def mock_session(form_html=FORM_HTML, ask_for_time=True): + session = MagicMock() + session.headers = {} + if ask_for_time: + session.post.return_value = html_response("Please provide your Receipt time") + session.get.side_effect = [html_response(form_html), html_response(RECEIPT_HTML)] + else: + session.post.return_value = html_response(RECEIPT_HTML) + session.get.side_effect = [html_response(form_html)] + return session + + +def make_tra_doc(code, items=None, **values): + doc = frappe.get_doc( + { + "doctype": "TRA TAX Inv", + "type": "Sales", + "verification_code": code, + "company_name": "ACME LTD", + "customer_name": "John Doe", + "grand_total": 200, + "items": items if items is not None else [{"description": ITEM, "quantity": "2", "amount": 100}], + **values, + } + ) + return doc.insert(ignore_links=True) + + +class TestVerifyTraReceipt(IntegrationTestCase): + def verify(self, session=None, **kwargs): + with patch(SESSION, return_value=session or mock_session()): + return tra.verify_tra_receipt(**kwargs) + + def test_creates_verified_document_from_receipt(self): + session = mock_session() + result = self.verify(session, verification_code=CODE) + self.assertTrue(result["success"], result) + self.assertEqual(result["company_name"], "ACME LTD") + self.assertEqual(result["receipt_number"], "RCT-001") + self.assertEqual(result["total"], 2500) + + self.assertEqual( + session.post.call_args.kwargs["data"], {"__RequestVerificationToken": "tok123", "RctVcode": CODE} + ) + self.assertIn("Secret=10:20:30", session.get.call_args_list[1].args[0]) + + doc = frappe.get_doc("TRA TAX Inv", result["doc_name"]) + self.assertEqual(doc.verification_status, "Verified") + self.assertEqual(doc.type, "Sales") + self.assertEqual(doc.customer_name, "John Doe") + self.assertEqual(doc.customer_id_type, "TIN") + self.assertEqual(doc.customer_id, "987654321") + self.assertEqual(doc.customer_mobile, "0755000000") + self.assertEqual(doc.subtotal, 2118.64) + self.assertEqual(doc.total_tax, 381.36) + self.assertEqual(doc.grand_total, 2500) + self.assertEqual([row.description for row in doc.items], [ITEM, "Unknown Widget"]) + self.assertEqual(doc.items[0].quantity, "2") + self.assertEqual(doc.items[0].amount, 1000) + + def test_duplicate_code_is_rejected(self): + self.verify(verification_code="DUP123_102030") + result = self.verify(verification_code="DUP123_102030") + self.assertFalse(result["success"]) + self.assertIn("already exists", result["message"]) + + def test_accepts_qr_code_url(self): + result = self.verify(qr_code_data="https://verify.tra.go.tz/QRC123_102030") + self.assertTrue(result["success"]) + self.assertEqual(result["verification_code"], "QRC123_102030") + result = self.verify(verification_code="https://verify.tra.go.tz/URL123_102030") + self.assertEqual(result["verification_code"], "URL123_102030") + + def test_rejects_invalid_input(self): + result = tra.verify_tra_receipt(qr_code_data="nonsense") + self.assertFalse(result["success"]) + self.assertEqual(result["message"], "Invalid QR code data format") + result = tra.verify_tra_receipt() + self.assertEqual(result["message"], "No verification code provided") + + def test_creates_failed_document_when_tra_is_unreachable(self): + session = MagicMock() + session.headers = {} + session.get.side_effect = requests.exceptions.ConnectionError("offline") + result = self.verify(session, verification_code="OFF123_102030") + self.assertTrue(result["success"]) + doc = frappe.get_doc("TRA TAX Inv", result["doc_name"]) + self.assertEqual(doc.verification_status, "Failed") + self.assertFalse(doc.items) + + def test_guest_cannot_create_documents(self): + frappe.set_user("Guest") + self.addCleanup(frappe.set_user, "Administrator") + result = self.verify(verification_code="GST123_102030") + self.assertFalse(result["success"]) + self.assertIn("Failed to create document", result["message"]) + + +class TestFetchTraVerification(IntegrationTestCase): + def test_validates_verification_code_format(self): + self.assertIn("does not contain time", tra.fetch_tra_verification("ABC123")["error"]) + self.assertIn("Invalid time format", tra.fetch_tra_verification("ABC123_12")["error"]) + + def test_requires_form_token(self): + with patch(SESSION, return_value=mock_session(form_html="")): + result = tra.fetch_tra_verification(CODE) + self.assertEqual(result["error"], "Could not find verification token in form") + + def test_parses_receipt_without_time_prompt(self): + session = mock_session(ask_for_time=False) + with patch(SESSION, return_value=session): + result = tra.fetch_tra_verification(CODE) + self.assertEqual(session.get.call_count, 1) + self.assertEqual(result["receipt_time_used"], "10:20:30") + self.assertEqual(result["form_token_used"], "tok123") + self.assertEqual(result["title"], "TRA Receipt") + self.assertEqual(result["verification_data"]["html_content"], RECEIPT_HTML) + self.assertEqual(len(result["verification_data"]["tables"]), 2) + + def test_request_errors_are_reported(self): + session = MagicMock() + session.headers = {} + session.get.side_effect = requests.exceptions.Timeout("slow") + with patch(SESSION, return_value=session): + result = tra.fetch_tra_verification(CODE) + self.assertIn("Request failed", result["error"]) + + +class TestReceiptParsing(IntegrationTestCase): + def test_extract_receipt_from_html(self): + data = tra.extract_receipt_from_html(RECEIPT_HTML) + self.assertEqual(data["company_info"]["name"], "ACME LTD") + self.assertEqual(data["company_info"]["tin"], "123456789") + self.assertEqual(data["company_info"]["vrn"], "40-123456-A") + self.assertEqual(data["company_info"]["serial_number"], "10TZ100001") + self.assertEqual(data["company_info"]["uin"], "UIN123") + self.assertEqual(data["company_info"]["tax_office"], "Ilala") + self.assertEqual(data["company_info"]["mobile"], "0712345678") + self.assertEqual(data["company_info"]["address"], "P.O.BOX 123 DAR") + self.assertEqual( + data["receipt_info"], + {"receipt_number": "RCT-001", "z_number": "Z-77", "date": "25/08/2026", "time": "10:20:30"}, + ) + self.assertEqual( + data["totals"], {"subtotal": "2,118.64", "total_tax": "381.36", "grand_total": "2,500.00"} + ) + self.assertEqual(data["taxes"], [{"label": "TAX RATE A (18%)", "amount": "381.36", "rate": "18%"}]) + self.assertEqual(data["verification_info"]["code"], CODE) + self.assertEqual(data["verification_info"]["verification_url"], f"https://verify.tra.go.tz/{CODE}") + self.assertEqual(data["verification_info"]["qr_code_data"], f"https://verify.tra.go.tz/{CODE}") + + def test_extract_verification_data(self): + data = tra.extract_verification_data(BeautifulSoup(RECEIPT_HTML, "html.parser")) + self.assertEqual(data["tables"][0][1], [ITEM, "2", "1,000.00"]) + self.assertIn("ACME LTD", data["all_text"]) + self.assertNotIn("form_inputs", data) + form = tra.extract_verification_data(BeautifulSoup(FORM_HTML, "html.parser")) + self.assertEqual(form["form_inputs"][0]["name"], "__RequestVerificationToken") + + def test_extract_receipt_data_prefers_html(self): + data = tra.extract_receipt_data({"html_content": RECEIPT_HTML}) + self.assertEqual(data["receipt_info"]["receipt_number"], "RCT-001") + self.assertEqual(tra.extract_receipt_data({})["items"], []) + self.assertEqual(tra.extract_receipt_data({"status_elements": []})["totals"], {}) + + def test_extract_receipt_data_from_tables(self): + tables = [ + [["Description", "Qty", "Amount"], ["Widget", "1", "10"], ["", "", ""]], + [ + ["Total Excl", "100"], + ["Total Incl", "118"], + ["Tax Total", "18"], + ["VAT", "18"], + ["Receipt No", "R1"], + ["Date", "2026-08-25"], + ["Time", "10:00"], + ["TIN", "111"], + ["VRN", "222"], + ], + ] + data = tra.extract_receipt_data({"tables": tables}) + self.assertEqual(data["items"], [{"description": "Widget", "quantity": "1", "amount": "10"}]) + self.assertEqual(data["totals"], {"subtotal": "100", "grand_total": "118", "total_tax": "18"}) + self.assertEqual(data["taxes"], [{"type": "VAT", "amount": "18"}]) + self.assertEqual( + data["receipt_info"], {"receipt_number": "R1", "date": "2026-08-25", "time": "10:00"} + ) + self.assertEqual(data["company_info"], {"tin": "111", "vrn": "222"}) + + def test_create_tra_tax_inv_document(self): + receipt = tra.extract_receipt_from_html(RECEIPT_HTML) + result = tra.create_tra_tax_inv_document("NEW123_102030", receipt, {"url": "https://x"}) + self.assertTrue(result["success"]) + doc = frappe.get_doc("TRA TAX Inv", result["doc_name"]) + self.assertEqual(doc.type, "Purchase") + self.assertEqual(doc.grand_total, 2500) + self.assertEqual(len(doc.items), 2) + duplicate = tra.create_tra_tax_inv_document("NEW123_102030", receipt, {}) + self.assertFalse(duplicate["success"]) + self.assertEqual(duplicate["existing_doc"], doc.name) + + def test_create_tra_tax_inv_document_safe_handles_bad_amounts(self): + receipt = { + "totals": {"subtotal": "n/a", "grand_total": "1,0"}, + "items": [{"description": "X", "amount": "?"}], + } + result = tra.create_tra_tax_inv_document_safe("BAD123_102030", receipt, {"success": False}) + self.assertTrue(result["success"]) + doc = frappe.get_doc("TRA TAX Inv", result["doc_name"]) + self.assertEqual(doc.verification_status, "Failed") + self.assertEqual(doc.subtotal, 0) + self.assertEqual(doc.grand_total, 10) + self.assertEqual(doc.items[0].amount, 0) + self.assertEqual(result["items_count"], 1) + + +class TestCreateInvoiceFromTraTaxInv(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + frappe.defaults.set_user_default("company", COMPANY) + frappe.clear_cache(user="Administrator") + assert frappe.defaults.get_user_default("Company") == COMPANY + + def test_creates_sales_invoice_and_customer(self): + doc = make_tra_doc("SI0001_102030") + result = tra.create_invoice_from_tra_tax_inv(doc.name, "Sales Invoice") + self.assertTrue(result["success"], result) + invoice = frappe.get_doc("Sales Invoice", result["invoice_name"]) + self.assertEqual(invoice.customer_name, "ACME LTD") + self.assertEqual(invoice.items[0].item_code, ITEM) + self.assertEqual(invoice.items[0].qty, 2) + self.assertEqual(invoice.items[0].rate, 100) + self.assertEqual(invoice.docstatus, 0) + doc.reload() + self.assertEqual(doc.reference_doctype, "Sales Invoice") + self.assertEqual(doc.reference_docname, invoice.name) + again = tra.create_invoice_from_tra_tax_inv(doc.name, "Sales Invoice") + self.assertFalse(again["success"]) + self.assertIn("Invoice already created", again["message"]) + + def test_creates_purchase_invoice_and_supplier(self): + doc = make_tra_doc("PI0001_102030", type="Purchase", receipt_number="RCT-9") + result = doc.create_purchase_invoice() + self.assertTrue(result["success"], result) + invoice = frappe.get_doc("Purchase Invoice", result["invoice_name"]) + self.assertEqual(invoice.supplier_name, "John Doe") + self.assertEqual(invoice.bill_no, "RCT-9") + self.assertEqual(invoice.items[0].item_code, ITEM) + self.assertTrue(frappe.db.exists("Supplier", {"supplier_name": "John Doe"})) + + def test_document_method_creates_sales_invoice(self): + doc = make_tra_doc("SI0002_102030") + self.assertTrue(doc.create_sales_invoice()["success"]) + + def test_validation_reports_missing_masters(self): + empty = make_tra_doc("EMP001_102030", items=[]) + result = tra.create_invoice_from_tra_tax_inv(empty.name, "Sales Invoice") + self.assertIn("No items found", result["message"]) + + missing = make_tra_doc( + "MIS001_102030", + items=[ + {"description": "Unknown Widget"}, + {"description": "Mapped", "mapped_item_code": "_Missing Item"}, + ], + ) + result = tra.create_invoice_from_tra_tax_inv(missing.name, "Sales Invoice") + self.assertFalse(result["success"]) + self.assertEqual(result["missing_items"], ["Unknown Widget", "Mapped (mapped to: _Missing Item)"]) + + no_company = make_tra_doc("NOC001_102030", company_name="") + result = tra.create_invoice_from_tra_tax_inv(no_company.name, "Sales Invoice") + self.assertIn("Customer: No company name", result["missing_party"]) + no_customer = make_tra_doc("NOS001_102030", customer_name="") + result = tra.create_invoice_from_tra_tax_inv(no_customer.name, "Purchase Invoice") + self.assertIn("Supplier: No customer name", result["missing_party"]) + + def test_invalid_invoice_type(self): + doc = make_tra_doc("TYP001_102030") + result = tra.create_invoice_from_tra_tax_inv(doc.name, "Journal Entry") + self.assertIn("Invalid invoice type", result["message"]) + + def test_user_without_permission_cannot_create_invoice(self): + doc = make_tra_doc("PRM001_102030") + user = "vfd-noperm@example.com" + if not frappe.db.exists("User", user): + frappe.get_doc( + {"doctype": "User", "email": user, "first_name": "No Perm", "send_welcome_email": 0} + ).insert() + frappe.set_user(user) + self.addCleanup(frappe.set_user, "Administrator") + result = tra.create_invoice_from_tra_tax_inv(doc.name, "Sales Invoice") + self.assertFalse(result["success"]) + + def test_get_or_suggest_item(self): + self.assertEqual(tra.get_or_suggest_item(frappe._dict(mapped_item_code=ITEM, description="x")), ITEM) + self.assertEqual( + tra.get_or_suggest_item(frappe._dict(mapped_item_code="_Nope", description=ITEM)), ITEM + ) + self.assertEqual(tra.get_or_suggest_item(frappe._dict(description="_Test Item")), "_Test Item") + self.assertEqual(tra.get_or_suggest_item(frappe._dict(description="Unknown")), "Unknown") + self.assertIsNone(tra.get_or_suggest_item(frappe._dict(description=""))) + + def test_get_or_create_party(self): + self.assertIsNone(tra.get_or_create_customer("")) + self.assertIsNone(tra.get_or_create_supplier("")) + self.assertEqual(tra.get_or_create_customer("_Test Customer"), "_Test Customer") + self.assertEqual(tra.get_or_create_supplier("_Test Supplier"), "_Test Supplier") + created = tra.get_or_create_customer("Brand New Customer") + customer_group = frappe.db.get_value("Customer", created, "customer_group") + self.assertEqual(frappe.db.get_value("Customer Group", customer_group, "is_group"), 0) + self.assertEqual(tra.get_or_create_customer("Brand New Customer"), created) + supplier = tra.get_or_create_supplier("Brand New Supplier") + self.assertEqual(frappe.db.get_value("Supplier", supplier, "supplier_type"), "Company") + + def test_get_or_suggest_party(self): + self.assertIsNone(tra.get_or_suggest_customer("")) + self.assertIsNone(tra.get_or_suggest_supplier("")) + self.assertEqual(tra.get_or_suggest_customer("_Test Customer"), "_Test Customer") + self.assertEqual(tra.get_or_suggest_customer("Ghost"), "Ghost") + self.assertEqual(tra.get_or_suggest_supplier("_Test Supplier"), "_Test Supplier") + self.assertEqual(tra.get_or_suggest_supplier("Ghost"), "Ghost") From c8fcf2daac3c09da0423032391159a9ef40d34da Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:48 +0300 Subject: [PATCH 67/74] test: add shared records for the fiscal device tests --- csf_tz/tests/vfd_test_records.py | 245 +++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 csf_tz/tests/vfd_test_records.py diff --git a/csf_tz/tests/vfd_test_records.py b/csf_tz/tests/vfd_test_records.py new file mode 100644 index 00000000..d04b77a1 --- /dev/null +++ b/csf_tz/tests/vfd_test_records.py @@ -0,0 +1,245 @@ +"""Shared VFD test records: providers, settings, tax templates, customers and invoices.""" + +import json +from unittest.mock import MagicMock, patch + +import frappe +from frappe.utils import add_days, nowdate + +COMPANY = "_Test Company" +CUSTOMER = "_Test VFD Customer" +ITEM = "_Test Non Stock Item" +SECOND_ITEM = "_Test VFD Service Item" +VAT_ACCOUNT = "_Test Account VAT - _TC" +STANDARD_TEMPLATE = "VFD Standard Rate - _TC" +EXEMPT_TEMPLATE = "VFD Exempt - _TC" +STANDARD_ZERO_TEMPLATE = "VFD Standard Zero - _TC" +STANDARD_OTHER_TEMPLATE = "VFD Standard Other - _TC" +NO_CODE_TEMPLATE = "VFD No Code - _TC" + +PROVIDERS = { + "VFDPlus": { + "settings": "VFDPlus Settings", + "base_url": "https://vfdplus.test/api/", + "attributes": {"post_fiscal_receipt": "receipt", "serial_info": "serial", "account_info": "account"}, + }, + "TotalVFD": { + "settings": "Total VFD Setting", + "base_url": "https://totalvfd.test/", + "attributes": {"sales": "sales"}, + }, + "SimplifyVFD": { + "settings": "Simplify VFD Settings", + "base_url": "https://simplify.test/", + "attributes": {"login": "login", "refresh": "refresh", "createIssuedInvoice": "invoice"}, + }, +} + +SERIAL_INFO = { + "msg_status": "OK", + "msg_code": 2000, + "msg_data": {"serial_id": "SER-1", "serial_code": "CRED-1", "tin": "123456789", "vat_enabled": 1}, +} + + +def fake_response(body, status_code=200, ok=True): + response = MagicMock() + response.ok = ok + response.status_code = status_code + response.text = json.dumps(body) + response.json.return_value = body + response.url = "https://mocked.test/" + response.headers = {} + response.cookies = {} + return response + + +def make_item_tax_template(name, vfd_taxcode, rate): + if frappe.db.exists("Item Tax Template", name): + return name + doc = frappe.get_doc( + { + "doctype": "Item Tax Template", + "title": name.replace(" - _TC", ""), + "company": COMPANY, + "vfd_taxcode": vfd_taxcode, + "taxes": [{"tax_type": VAT_ACCOUNT, "tax_rate": rate}], + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def make_item_tax_templates(): + make_item_tax_template(STANDARD_TEMPLATE, "1- Standard Rate (18%)", 18) + make_item_tax_template(EXEMPT_TEMPLATE, "5- Exempt", 0) + make_item_tax_template(STANDARD_ZERO_TEMPLATE, "1- Standard Rate (18%)", 0) + make_item_tax_template(STANDARD_OTHER_TEMPLATE, "1- Standard Rate (18%)", 10) + make_item_tax_template(NO_CODE_TEMPLATE, "", 18) + + +def make_second_item(): + if frappe.db.exists("Item", SECOND_ITEM): + return SECOND_ITEM + frappe.get_doc( + { + "doctype": "Item", + "item_code": SECOND_ITEM, + "item_name": SECOND_ITEM, + "item_group": "_Test Item Group", + "stock_uom": "Nos", + "is_stock_item": 0, + } + ).insert(ignore_permissions=True) + return SECOND_ITEM + + +def make_customer(name=CUSTOMER, tax_id="123-456-789"): + if frappe.db.exists("Customer", name): + return frappe.get_doc("Customer", name) + doc = frappe.get_doc( + { + "doctype": "Customer", + "customer_name": name, + "customer_group": "_Test Customer Group", + "territory": "_Test Territory", + "tax_id": tax_id, + "mobile_no": "+255 (0)712-345-678", + } + ) + doc.insert(ignore_permissions=True) + return doc + + +def make_vfd_providers(): + for name, info in PROVIDERS.items(): + if frappe.db.exists("VFD Provider", name): + continue + frappe.get_doc( + { + "doctype": "VFD Provider", + "vfd_provider": name, + "vfd_provider_settings": info["settings"], + "base_url": info["base_url"], + "attributes": [{"key": key, "value": value} for key, value in info["attributes"].items()], + } + ).insert(ignore_permissions=True) + + +def set_company_provider(provider, company=COMPANY): + if frappe.db.exists("Company VFD Provider", company): + doc = frappe.get_doc("Company VFD Provider", company) + doc.vfd_provider = provider + doc.save(ignore_permissions=True) + else: + doc = frappe.get_doc( + {"doctype": "Company VFD Provider", "company": company, "vfd_provider": provider} + ).insert(ignore_permissions=True) + frappe.clear_document_cache("Company VFD Provider", company) + return doc + + +def make_vfdplus_settings(company=COMPANY): + if frappe.db.exists("VFDPlus Settings", company): + return frappe.get_doc("VFDPlus Settings", company) + doc = frappe.get_doc( + { + "doctype": "VFDPlus Settings", + "company": company, + "vfdplus_api_key": "plus-key", + "vfd_start_date": add_days(nowdate(), -30), + } + ) + with patch( + "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.requests.request", + return_value=fake_response(SERIAL_INFO), + ): + doc.insert(ignore_permissions=True) + return doc + + +def make_total_vfd_setting(company=COMPANY): + if frappe.db.exists("Total VFD Setting", company): + return frappe.get_doc("Total VFD Setting", company) + return frappe.get_doc( + { + "doctype": "Total VFD Setting", + "company": company, + "serial_id": "TOTAL-SERIAL", + "bearer_token": "total-token", + "x_active_business": "business-1", + "vfd_start_date": add_days(nowdate(), -30), + } + ).insert(ignore_permissions=True) + + +def make_simplify_settings(company=COMPANY): + if frappe.db.exists("Simplify VFD Settings", company): + return frappe.get_doc("Simplify VFD Settings", company) + return frappe.get_doc( + { + "doctype": "Simplify VFD Settings", + "company": company, + "username": "simplify-user", + "password": "simplify-pass", + "bearer_token": "simplify-token", + "refresh_token": "simplify-refresh", + "vfd_start_date": add_days(nowdate(), -30), + } + ).insert(ignore_permissions=True) + + +def make_sales_invoice( + item_tax_template=STANDARD_TEMPLATE, tax_rate=18, with_taxes=True, submit=False, **values +): + items = values.pop("items", None) or [{"item_code": ITEM, "qty": 2, "rate": 100}] + doc = frappe.get_doc( + { + "doctype": "Sales Invoice", + "company": COMPANY, + "customer": values.pop("customer", CUSTOMER), + "debit_to": "Debtors - _TC", + "currency": "INR", + "conversion_rate": 1, + "set_posting_time": 1, + "posting_date": nowdate(), + "due_date": nowdate(), + **values, + } + ) + for item in items: + doc.append( + "items", + { + "item_tax_template": item_tax_template, + "income_account": "Sales - _TC", + "cost_center": "_Test Cost Center - _TC", + **item, + }, + ) + if with_taxes: + doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": VAT_ACCOUNT, + "rate": tax_rate, + "description": "VAT", + "cost_center": "_Test Cost Center - _TC", + }, + ) + doc.insert() + if submit: + doc.submit() + return doc + + +def make_vfd_records(): + """Create every VFD test record on the test company.""" + make_item_tax_templates() + make_second_item() + make_customer() + make_vfd_providers() + make_total_vfd_setting() + make_simplify_settings() + make_vfdplus_settings() From 1d765f5799957aa814ae9f5ed163bcd714f24d33 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:54 +0300 Subject: [PATCH 68/74] test: record the whitelisted method inventory used by the tests --- csf_tz/tests/whitelisted_methods.json | 803 ++++++++++++++++++++++++++ 1 file changed, 803 insertions(+) create mode 100644 csf_tz/tests/whitelisted_methods.json diff --git a/csf_tz/tests/whitelisted_methods.json b/csf_tz/tests/whitelisted_methods.json new file mode 100644 index 00000000..1218cb70 --- /dev/null +++ b/csf_tz/tests/whitelisted_methods.json @@ -0,0 +1,803 @@ +[ + { + "path": "csf_tz.api.selcom.create_order_minimal", + "args": [], + "decorator": "frappe.whitelist()", + "line": 22, + "in_class": null + }, + { + "path": "csf_tz.bank_api.receive_callback", + "args": [], + "decorator": "frappe.whitelist(allow_guest=True)", + "line": 21, + "in_class": null + }, + { + "path": "csf_tz.bank_api.receive_validate_reference", + "args": [], + "decorator": "frappe.whitelist(allow_guest=True)", + "line": 27, + "in_class": null + }, + { + "path": "csf_tz.budget_check.check_budget_before_submit", + "args": [ + "doctype", + "docname", + "setting_field" + ], + "decorator": "frappe.whitelist()", + "line": 86, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.get", + "args": [ + "chart_name", + "chart", + "no_cache", + "filters", + "from_date", + "to_date", + "timespan", + "time_interval", + "heatmap_year" + ], + "decorator": "frappe.whitelist()", + "line": 13, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.get_sample_data", + "args": [ + "chart_name" + ], + "decorator": "frappe.whitelist()", + "line": 33, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.create_sample_accounts", + "args": [ + "company" + ], + "decorator": "frappe.whitelist()", + "line": 68, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.debug_chart_data", + "args": [ + "company" + ], + "decorator": "frappe.whitelist()", + "line": 83, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.create_test_transactions", + "args": [ + "company", + "account_name", + "amount" + ], + "decorator": "frappe.whitelist()", + "line": 872, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.dashboard_chart_source.multi_account_balance_timeline.multi_account_balance_timeline.create_sample_accounts", + "args": [ + "self", + "company" + ], + "decorator": "frappe.whitelist()", + "line": 612, + "in_class": "MultiBankBalance" + }, + { + "path": "csf_tz.csf_tz.doctype.efd_z_report.efd_z_report.get_sales_invoice", + "args": [ + "self" + ], + "decorator": "frappe.whitelist()", + "line": 25, + "in_class": "EFDZReport" + }, + { + "path": "csf_tz.csf_tz.doctype.foreign_import_transaction.foreign_import_transaction.recalculate_differences", + "args": [ + "self" + ], + "decorator": "frappe.whitelist()", + "line": 146, + "in_class": "ForeignImportTransaction" + }, + { + "path": "csf_tz.csf_tz.doctype.foreign_import_transaction.foreign_import_transaction.get_exchange_summary", + "args": [ + "self" + ], + "decorator": "frappe.whitelist()", + "line": 156, + "in_class": "ForeignImportTransaction" + }, + { + "path": "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.update_latra_records", + "args": [ + "force" + ], + "decorator": "frappe.whitelist()", + "line": 82, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.latra_licenses.latra_licenses.update_latra_offences", + "args": [ + "force" + ], + "decorator": "frappe.whitelist()", + "line": 116, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.tra_tax_inv.tra_tax_inv.verify_tra_receipt", + "args": [ + "verification_code", + "qr_code_data" + ], + "decorator": "frappe.whitelist(allow_guest=True)", + "line": 26, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.tra_tax_inv.tra_tax_inv.create_invoice_from_tra_tax_inv", + "args": [ + "tra_tax_inv_name", + "invoice_type" + ], + "decorator": "frappe.whitelist()", + "line": 822, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.tz_insurance_cover_note.tz_insurance_cover_note.update_covernote_docs", + "args": [], + "decorator": "frappe.whitelist()", + "line": 25, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.vehicle_fine_record.vehicle_fine_record.get_fine", + "args": [ + "number_plate" + ], + "decorator": "frappe.whitelist()", + "line": 252, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.vehicle_sync_task.processor.run_vehicle_batch", + "args": [], + "decorator": "frappe.whitelist()", + "line": 49, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.vehicle_sync_task.processor.create_sync_task", + "args": [ + "vehicle_no", + "priority", + "immediate" + ], + "decorator": "frappe.whitelist()", + "line": 89, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.doctype.vehicle_sync_task.processor.seed_vehicle_sync_queue", + "args": [], + "decorator": "frappe.whitelist()", + "line": 161, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.page.jobcards.jobcards.get_job_cards", + "args": [], + "decorator": "frappe.whitelist()", + "line": 7, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.page.jobcards.jobcards.get_employees", + "args": [ + "company" + ], + "decorator": "frappe.whitelist()", + "line": 26, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.page.jobcards.jobcards.save_doc", + "args": [ + "doc", + "action" + ], + "decorator": "frappe.whitelist()", + "line": 39, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.page.scan_qrcode.scan_qrcode.add_biometric_log", + "args": [ + "data" + ], + "decorator": "frappe.whitelist()", + "line": 5, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.report.salary_register_csf.salary_register_csf.approve", + "args": [ + "data" + ], + "decorator": "frappe.whitelist()", + "line": 461, + "in_class": null + }, + { + "path": "csf_tz.csf_tz.report.salary_register_ctc.salary_register_ctc.approve", + "args": [ + "data" + ], + "decorator": "frappe.whitelist()", + "line": 277, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.additional_salary.create_additional_salary_journal", + "args": [ + "doc", + "method" + ], + "decorator": "frappe.whitelist()", + "line": 7, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.additional_salary.generate_additional_salary_records", + "args": [], + "decorator": "frappe.whitelist()", + "line": 76, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.additional_salary.get_employee_base_salary_in_hours", + "args": [ + "employee", + "payroll_date" + ], + "decorator": "frappe.whitelist()", + "line": 151, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.customer.get_customer_total_unpaid_amount", + "args": [ + "customer", + "company" + ], + "decorator": "frappe.whitelist()", + "line": 6, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.employee_contact_qr.generate_contact_qr", + "args": [ + "employee" + ], + "decorator": "frappe.whitelist()", + "line": 10, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.exchange_calculations.create_manual_exchange_entry", + "args": [ + "tracker_name", + "reference_type", + "reference_name", + "difference_type", + "amount", + "remarks" + ], + "decorator": "frappe.whitelist()", + "line": 477, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.exchange_calculations.debug_payment_linking_issue", + "args": [ + "payment_entry_name" + ], + "decorator": "frappe.whitelist()", + "line": 517, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.exchange_calculations.manually_link_payment_to_tracker", + "args": [ + "payment_entry_name", + "tracker_name" + ], + "decorator": "frappe.whitelist()", + "line": 598, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.item_reposting.enqueue_reposting_sle_gle", + "args": [], + "decorator": "frappe.whitelist()", + "line": 102, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.items_revaluation.get_data", + "args": [ + "filters" + ], + "decorator": "frappe.whitelist()", + "line": 6, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.landed_cost_voucher.get_landed_cost_expenses", + "args": [ + "import_file" + ], + "decorator": "frappe.whitelist()", + "line": 5, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.payment_entry.get_outstanding_reference_documents", + "args": [ + "args" + ], + "decorator": "frappe.whitelist()", + "line": 19, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.payment_entry.get_outstanding_sales_orders", + "args": [ + "args" + ], + "decorator": "frappe.whitelist()", + "line": 148, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.payroll.update_slips", + "args": [ + "payroll_entry" + ], + "decorator": "frappe.whitelist()", + "line": 64, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.payroll.update_slip", + "args": [ + "salary_slip", + "show_message" + ], + "decorator": "frappe.whitelist()", + "line": 103, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.payroll.print_slips", + "args": [ + "payroll_entry" + ], + "decorator": "frappe.whitelist()", + "line": 121, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.payroll.create_journal_entry", + "args": [ + "payroll_entry" + ], + "decorator": "frappe.whitelist()", + "line": 213, + "in_class": null + }, + { + "path": "csf_tz.csftz_hooks.payroll.get_amounts_summary", + "args": [ + "payroll_entry" + ], + "decorator": "frappe.whitelist()", + "line": 292, + "in_class": null + }, + { + "path": "csf_tz.custom_api.generate_qrcode", + "args": [ + "qrcode_data" + ], + "decorator": "frappe.whitelist()", + "line": 24, + "in_class": null + }, + { + "path": "csf_tz.custom_api.app_error_log", + "args": [ + "title", + "error" + ], + "decorator": "frappe.whitelist()", + "line": 33, + "in_class": null + }, + { + "path": "csf_tz.custom_api.print_out", + "args": [ + "message", + "alert", + "add_traceback", + "to_error_log" + ], + "decorator": "frappe.whitelist()", + "line": 38, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_item_info", + "args": [ + "item_code" + ], + "decorator": "frappe.whitelist()", + "line": 128, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_item_prices", + "args": [ + "item_code", + "currency", + "customer", + "company" + ], + "decorator": "frappe.whitelist()", + "line": 164, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_item_prices_custom", + "args": [ + "filters", + "start", + "limit" + ], + "decorator": "frappe.whitelist()", + "line": 209, + "in_class": null + }, + { + "path": "csf_tz.custom_api.create_delivery_note", + "args": [ + "doc", + "method", + "doc_name" + ], + "decorator": "frappe.whitelist()", + "line": 274, + "in_class": null + }, + { + "path": "csf_tz.custom_api.make_delivery_note", + "args": [ + "source_name", + "target_doc", + "set_warehouse" + ], + "decorator": "frappe.whitelist()", + "line": 330, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_pending_sales_invoice", + "args": [], + "decorator": "frappe.whitelist()", + "line": 565, + "in_class": null + }, + { + "path": "csf_tz.custom_api.make_stock_reconciliation_for_all_pending_material_request", + "args": [], + "decorator": "frappe.whitelist()", + "line": 736, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_warehouse_options", + "args": [ + "company" + ], + "decorator": "frappe.whitelist()", + "line": 807, + "in_class": null + }, + { + "path": "csf_tz.custom_api.make_withholding_tax_gl_entries_for_purchase", + "args": [ + "doc", + "method" + ], + "decorator": "frappe.whitelist()", + "line": 876, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_tax_category", + "args": [ + "doc_type", + "company" + ], + "decorator": "frappe.whitelist()", + "line": 984, + "in_class": null + }, + { + "path": "csf_tz.custom_api.make_withholding_tax_gl_entries_for_sales", + "args": [ + "doc", + "method" + ], + "decorator": "frappe.whitelist()", + "line": 1015, + "in_class": null + }, + { + "path": "csf_tz.custom_api.account_exists", + "args": [ + "account_name" + ], + "decorator": "frappe.whitelist()", + "line": 1595, + "in_class": null + }, + { + "path": "csf_tz.custom_api.auto_create_account", + "args": [ + "abbr" + ], + "decorator": "frappe.whitelist()", + "line": 1600, + "in_class": null + }, + { + "path": "csf_tz.custom_api.create_item_tax_template", + "args": [ + "abbr" + ], + "decorator": "frappe.whitelist()", + "line": 1684, + "in_class": null + }, + { + "path": "csf_tz.custom_api.create_tax_category", + "args": [], + "decorator": "frappe.whitelist()", + "line": 1724, + "in_class": null + }, + { + "path": "csf_tz.custom_api.linking_tax_template", + "args": [ + "doctype", + "default_tax_template", + "abbr" + ], + "decorator": "frappe.whitelist()", + "line": 1746, + "in_class": null + }, + { + "path": "csf_tz.custom_api.make_salary_components_and_structure", + "args": [ + "abbr" + ], + "decorator": "frappe.whitelist()", + "line": 1787, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_item_prices_custom_po", + "args": [ + "filters", + "start", + "limit" + ], + "decorator": "frappe.whitelist()", + "line": 2030, + "in_class": null + }, + { + "path": "csf_tz.custom_api.get_item_prices_po", + "args": [ + "item_code", + "currency", + "customer", + "company" + ], + "decorator": "frappe.whitelist()", + "line": 2095, + "in_class": null + }, + { + "path": "csf_tz.custom_api.create_write_off_jv_si", + "args": [ + "sales_invoice", + "account" + ], + "decorator": "frappe.whitelist()", + "line": 2141, + "in_class": null + }, + { + "path": "csf_tz.custom_api.create_write_off_jv_pi", + "args": [ + "purchase_invoice", + "account" + ], + "decorator": "frappe.whitelist()", + "line": 2196, + "in_class": null + }, + { + "path": "csf_tz.custom_api.create_write_off_jv_pe", + "args": [ + "payment_entry", + "account" + ], + "decorator": "frappe.whitelist()", + "line": 2251, + "in_class": null + }, + { + "path": "csf_tz.kcb.api.kcb_api.is_kcb_enabled", + "args": [], + "decorator": "frappe.whitelist()", + "line": 12, + "in_class": null + }, + { + "path": "csf_tz.kcb.api.kcb_api.check_file_status", + "args": [ + "docname" + ], + "decorator": "frappe.whitelist()", + "line": 176, + "in_class": null + }, + { + "path": "csf_tz.kcb.payments.make_kcb_payments_initiation_from_payment_entries", + "args": [ + "payment_entries" + ], + "decorator": "frappe.whitelist()", + "line": 172, + "in_class": null + }, + { + "path": "csf_tz.kcb.payments.make_kcb_payments_initiation_from_payroll_entry", + "args": [ + "payroll_entry_name" + ], + "decorator": "frappe.whitelist()", + "line": 278, + "in_class": null + }, + { + "path": "csf_tz.stanbic.payments.make_payments_initiation", + "args": [ + "payroll_entry_name", + "currency", + "stanbic_setting_name" + ], + "decorator": "frappe.whitelist()", + "line": 6, + "in_class": null + }, + { + "path": "csf_tz.utils.create_custom_fields.export_custom_fields", + "args": [ + "docnames" + ], + "decorator": "frappe.whitelist()", + "line": 66, + "in_class": null + }, + { + "path": "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.get_payload", + "args": [ + "doc" + ], + "decorator": "frappe.whitelist()", + "line": 122, + "in_class": null + }, + { + "path": "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.post_fiscal_receipt", + "args": [ + "doc", + "method", + "payload", + "invoice_id", + "preview" + ], + "decorator": "frappe.whitelist()", + "line": 192, + "in_class": null + }, + { + "path": "csf_tz.vfd_providers.doctype.simplify_vfd_settings.simplify_vfd_settings.get_bearer_token", + "args": [ + "self" + ], + "decorator": "frappe.whitelist()", + "line": 33, + "in_class": "SimplifyVFDSettings" + }, + { + "path": "csf_tz.vfd_providers.doctype.total_vfd_setting.total_vfd_setting.post_fiscal_receipt", + "args": [ + "doc", + "method", + "payload", + "invoice_id", + "preview" + ], + "decorator": "frappe.whitelist()", + "line": 112, + "in_class": null + }, + { + "path": "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.post_fiscal_receipt", + "args": [ + "doc", + "method", + "payload", + "invoice_id", + "preview" + ], + "decorator": "frappe.whitelist()", + "line": 224, + "in_class": null + }, + { + "path": "csf_tz.vfd_providers.doctype.vfdplus_settings.vfdplus_settings.get_account_info", + "args": [ + "company" + ], + "decorator": "frappe.whitelist()", + "line": 335, + "in_class": null + }, + { + "path": "csf_tz.vfd_support.utils.generate_tra_vfd", + "args": [ + "docname", + "sinv_doc", + "method", + "caller" + ], + "decorator": "frappe.whitelist()", + "line": 23, + "in_class": null + } +] \ No newline at end of file From d761e0938ec100bca430710b140ee7ac4f4b6989 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:54 +0300 Subject: [PATCH 69/74] test: replace the Bank Charges Pattern stub with real assertions --- .../bank_charges_pattern/test_bank_charges_pattern.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/csf_tz/csf_tz/doctype/bank_charges_pattern/test_bank_charges_pattern.py b/csf_tz/csf_tz/doctype/bank_charges_pattern/test_bank_charges_pattern.py index c2c97848..f71b987e 100644 --- a/csf_tz/csf_tz/doctype/bank_charges_pattern/test_bank_charges_pattern.py +++ b/csf_tz/csf_tz/doctype/bank_charges_pattern/test_bank_charges_pattern.py @@ -1,9 +1,8 @@ # Copyright (c) 2022, Aakvatech and Contributors # See license.txt -# import frappe -import unittest +from frappe.tests import IntegrationTestCase -class TestBankChargesPattern(unittest.TestCase): +class TestBankChargesPattern(IntegrationTestCase): pass From 854b2a11d509291136111560c1e175c366553665 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:00:59 +0300 Subject: [PATCH 70/74] test: replace the Csf Tz Bank Charges stub with real assertions --- .../doctype/csf_tz_bank_charges/test_csf_tz_bank_charges.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csf_tz/csf_tz/doctype/csf_tz_bank_charges/test_csf_tz_bank_charges.py b/csf_tz/csf_tz/doctype/csf_tz_bank_charges/test_csf_tz_bank_charges.py index 5a483dc1..6764947c 100644 --- a/csf_tz/csf_tz/doctype/csf_tz_bank_charges/test_csf_tz_bank_charges.py +++ b/csf_tz/csf_tz/doctype/csf_tz_bank_charges/test_csf_tz_bank_charges.py @@ -2,8 +2,8 @@ # See license.txt # import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests import IntegrationTestCase -class TestCSFTZBankCharges(FrappeTestCase): +class TestCSFTZBankCharges(IntegrationTestCase): pass From 0aea4a848934ecea613953fa94de2c45373693ba Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:01:05 +0300 Subject: [PATCH 71/74] test: replace the Foreign Import Settings stub with real assertions --- .../foreign_import_settings/test_foreign_import_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csf_tz/csf_tz/doctype/foreign_import_settings/test_foreign_import_settings.py b/csf_tz/csf_tz/doctype/foreign_import_settings/test_foreign_import_settings.py index b8ddb493..8459998a 100644 --- a/csf_tz/csf_tz/doctype/foreign_import_settings/test_foreign_import_settings.py +++ b/csf_tz/csf_tz/doctype/foreign_import_settings/test_foreign_import_settings.py @@ -2,8 +2,8 @@ # See license.txt # import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests import IntegrationTestCase -class TestForeignImportSettings(FrappeTestCase): +class TestForeignImportSettings(IntegrationTestCase): pass From 3f5d8245bde324635cebe3a482a53b9df6bc6e4b Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:01:10 +0300 Subject: [PATCH 72/74] test: replace the Foreign Import Transaction stub with real assertions --- .../test_foreign_import_transaction.py | 503 ++++-------------- 1 file changed, 101 insertions(+), 402 deletions(-) diff --git a/csf_tz/csf_tz/doctype/foreign_import_transaction/test_foreign_import_transaction.py b/csf_tz/csf_tz/doctype/foreign_import_transaction/test_foreign_import_transaction.py index 390350df..0b415e5a 100644 --- a/csf_tz/csf_tz/doctype/foreign_import_transaction/test_foreign_import_transaction.py +++ b/csf_tz/csf_tz/doctype/foreign_import_transaction/test_foreign_import_transaction.py @@ -2,456 +2,155 @@ # See license.txt import frappe -from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry -from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice -from frappe.tests.utils import FrappeTestCase +from frappe.tests import IntegrationTestCase from frappe.utils import nowdate +from csf_tz.csftz_hooks.exchange_calculations import create_import_tracker +from csf_tz.tests.import_fixtures import ( + INR_BANK, + INR_SUPPLIER, + ORIGINAL_RATE, + USD_SUPPLIER, + get_tracker, + make_foreign_purchase_invoice, + make_purchase_invoice, + make_supplier_payment, + set_import_settings, +) -class TestForeignImportTransaction(FrappeTestCase): - def setUp(self): - """Set up test data""" - self.company = "_Test Company" - self.supplier = "_Test Supplier USD" - self.currency = "USD" - self.original_rate = 2500.0 # 1 USD = 2500 TZS - self.payment_rate = 2600.0 # 1 USD = 2600 TZS (currency strengthened) +PAYMENT_RATE = 2600.0 - # Create test supplier group if not exists - if not frappe.db.exists("Supplier Group", "_Test Supplier Group"): - supplier_group = frappe.get_doc( - {"doctype": "Supplier Group", "supplier_group_name": "_Test Supplier Group"} - ) - supplier_group.insert(ignore_permissions=True) - # Create test supplier if not exists - if not frappe.db.exists("Supplier", self.supplier): - supplier_doc = frappe.get_doc( - { - "doctype": "Supplier", - "supplier_name": self.supplier, - "supplier_group": "_Test Supplier Group", - "supplier_type": "Company", - } - ) - supplier_doc.insert(ignore_permissions=True) - - # Create Foreign Import Settings if not exists - if not frappe.db.exists("Foreign Import Settings"): - settings = frappe.get_doc( - { - "doctype": "Foreign Import Settings", - "company": self.company, - "exchange_difference_threshold": 0.01, - "auto_create_journal_entries": 0, # Disable to avoid account setup issues - "enable_lcv_exchange_tracking": 1, - } - ) - settings.insert(ignore_permissions=True) - - def tearDown(self): - """Clean up test data""" - # Cancel and delete test documents - frappe.db.rollback() +class TestForeignImportTransaction(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + set_import_settings() def test_automatic_tracker_creation_on_foreign_pi_submit(self): - """Test that Foreign Import Transaction is created automatically when foreign PI is submitted""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - do_not_submit=True, - ) - - # Check no tracker exists before submission - tracker_before = frappe.db.exists("Foreign Import Transaction", {"purchase_invoice": pi.name}) - self.assertIsNone(tracker_before) - - # Submit the Purchase Invoice - pi.submit() - - # Check tracker is created after submission - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - self.assertIsNotNone(tracker_name) - - # Verify tracker details - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - self.assertEqual(tracker.purchase_invoice, pi.name) - self.assertEqual(tracker.supplier, pi.supplier) - self.assertEqual(tracker.currency, pi.currency) - self.assertEqual(tracker.original_exchange_rate, pi.conversion_rate) - self.assertEqual(tracker.invoice_amount_foreign, pi.grand_total) - self.assertEqual(tracker.invoice_amount_base, pi.base_grand_total) + invoice = make_foreign_purchase_invoice(do_not_submit=True) + self.assertIsNone(get_tracker(invoice.name)) + invoice.submit() + tracker = get_tracker(invoice.name) + self.assertEqual(tracker.supplier, USD_SUPPLIER) + self.assertEqual(tracker.currency, "USD") + self.assertEqual(tracker.original_exchange_rate, ORIGINAL_RATE) + self.assertEqual(tracker.invoice_amount_foreign, invoice.grand_total) + self.assertEqual(tracker.invoice_amount_base, invoice.base_grand_total) self.assertEqual(tracker.status, "Active") self.assertEqual(tracker.docstatus, 1) def test_no_tracker_creation_for_base_currency_pi(self): - """Test that no tracker is created for base currency Purchase Invoice""" - # Get company's default currency - company_currency = frappe.get_cached_value("Company", self.company, "default_currency") - - # Create base currency Purchase Invoice - pi = make_purchase_invoice( - supplier="_Test Supplier", - currency=company_currency, # Use actual company currency - rate=100, - do_not_submit=True, - ) - - pi.submit() - - # Check no tracker is created - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - self.assertIsNone(tracker_name) + invoice = make_purchase_invoice(supplier=INR_SUPPLIER, currency="INR", rate=100) + self.assertIsNone(get_tracker(invoice.name)) def test_payment_entry_linking_and_exchange_calculation(self): - """Test that Payment Entry links to tracker and calculates exchange differences""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - qty=10, # Total: 1000 USD - do_not_submit=True, - ) - pi.submit() - - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - - # Create Payment Entry with different exchange rate - pe = get_payment_entry("Purchase Invoice", pi.name) - pe.source_exchange_rate = self.payment_rate # Different rate - pe.paid_amount = 500 # Pay half the invoice - pe.base_paid_amount = 500 * self.payment_rate - pe.insert() - pe.submit() - - # Reload tracker to check if payment was linked - tracker.reload() - - # Verify payment was linked + invoice = make_foreign_purchase_invoice() + payment = make_supplier_payment(invoice.name, 500, PAYMENT_RATE) + tracker = get_tracker(invoice.name) self.assertEqual(len(tracker.payments), 1) - payment_row = tracker.payments[0] - self.assertEqual(payment_row.payment_entry, pe.name) - self.assertEqual(payment_row.payment_amount_foreign, 500) - self.assertEqual(payment_row.payment_exchange_rate, self.payment_rate) - - # Verify exchange difference calculation - expected_diff = 500 * (self.payment_rate - self.original_rate) # 500 * (2600 - 2500) = 50,000 - self.assertEqual(payment_row.exchange_difference, expected_diff) - - # Verify exchange difference entry was created - self.assertEqual(len(tracker.exchange_differences), 1) - diff_row = tracker.exchange_differences[0] - self.assertEqual(diff_row.reference_type, "Payment Entry") - self.assertEqual(diff_row.reference_name, pe.name) - self.assertEqual(diff_row.difference_type, "Gain") # Rate increased - self.assertEqual(diff_row.amount, expected_diff) - - # Verify status is still Active (partial payment) + row = tracker.payments[0] + self.assertEqual(row.payment_entry, payment.name) + self.assertEqual(row.payment_amount_foreign, 500) + self.assertEqual(row.payment_exchange_rate, PAYMENT_RATE) + self.assertEqual(row.exchange_difference, 500 * (PAYMENT_RATE - ORIGINAL_RATE)) + self.assertEqual(row.journal_entry_created, 0) + difference = tracker.exchange_differences[0] + self.assertEqual(difference.reference_type, "Payment Entry") + self.assertEqual(difference.reference_name, payment.name) + self.assertEqual(difference.difference_type, "Gain") + self.assertEqual(difference.amount, 50000) self.assertEqual(tracker.status, "Active") - - def test_status_change_to_completed_on_full_payment(self): - """Test that status changes to Completed when full payment is made""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - qty=10, # Total: 1000 USD - do_not_submit=True, + self.assertEqual( + frappe.db.get_value("Payment Entry", payment.name, "foreign_import_tracker"), tracker.name ) - pi.submit() - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - - # Create Payment Entry for full amount - pe = get_payment_entry("Purchase Invoice", pi.name) - pe.source_exchange_rate = self.payment_rate - pe.paid_amount = 1000 # Full payment - pe.base_paid_amount = 1000 * self.payment_rate - pe.insert() - pe.submit() - - # Reload tracker - tracker.reload() - - # Verify status changed to Completed - self.assertEqual(tracker.status, "Completed") + def test_status_change_to_completed_on_full_payment(self): + invoice = make_foreign_purchase_invoice() + make_supplier_payment(invoice.name, 1000, PAYMENT_RATE) + self.assertEqual(get_tracker(invoice.name).status, "Completed") def test_exchange_loss_calculation(self): - """Test exchange loss calculation when currency weakens""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - qty=10, - do_not_submit=True, - ) - pi.submit() - - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - - # Create Payment Entry with lower exchange rate (currency weakened) - weaker_rate = 2400.0 # 1 USD = 2400 TZS (currency weakened) - pe = get_payment_entry("Purchase Invoice", pi.name) - pe.source_exchange_rate = weaker_rate - pe.paid_amount = 500 - pe.base_paid_amount = 500 * weaker_rate - pe.insert() - pe.submit() - - # Reload tracker - tracker.reload() - - # Verify exchange loss calculation - expected_diff = 500 * (weaker_rate - self.original_rate) # 500 * (2400 - 2500) = -50,000 - payment_row = tracker.payments[0] - self.assertEqual(payment_row.exchange_difference, expected_diff) - - # Verify exchange difference entry shows Loss - diff_row = tracker.exchange_differences[0] - self.assertEqual(diff_row.difference_type, "Loss") - self.assertEqual(diff_row.amount, abs(expected_diff)) # Amount is always positive + invoice = make_foreign_purchase_invoice() + make_supplier_payment(invoice.name, 500, 2400) + tracker = get_tracker(invoice.name) + self.assertEqual(tracker.payments[0].exchange_difference, -50000) + self.assertEqual(tracker.exchange_differences[0].difference_type, "Loss") + self.assertEqual(tracker.exchange_differences[0].amount, 50000) def test_tracker_cancellation_on_pi_cancel(self): - """Test that tracker is cancelled when Purchase Invoice is cancelled""" - # Create and submit foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - do_not_submit=True, - ) - pi.submit() - - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - self.assertEqual(tracker.docstatus, 1) - - # Cancel the Purchase Invoice - pi.cancel() - - # Reload tracker and verify it's cancelled - tracker.reload() + invoice = make_foreign_purchase_invoice() + self.assertEqual(get_tracker(invoice.name).docstatus, 1) + invoice.cancel() + tracker = get_tracker(invoice.name) self.assertEqual(tracker.docstatus, 2) self.assertEqual(tracker.status, "Cancelled") def test_currency_validation(self): - """Test that tracker validates foreign currency requirement""" - # Get company's default currency - company_currency = frappe.get_cached_value("Company", self.company, "default_currency") - - # Create tracker manually with same currency as company + invoice = make_purchase_invoice(supplier=INR_SUPPLIER, currency="INR", rate=100) tracker = frappe.get_doc( { "doctype": "Foreign Import Transaction", - "purchase_invoice": "TEST-PI-001", - "supplier": self.supplier, - "currency": company_currency, # Same as company currency - "company": self.company, + "purchase_invoice": invoice.name, + "supplier": INR_SUPPLIER, + "currency": "INR", + "company": "_Test Company", + "transaction_date": nowdate(), } ) - - # Should throw error on validation with self.assertRaises(frappe.ValidationError): tracker.insert() def test_totals_calculation(self): - """Test that totals are calculated correctly""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - qty=10, - do_not_submit=True, - ) - pi.submit() - - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - - # Add manual exchange differences - tracker.add_exchange_difference("Manual Entry", "TEST-001", "Gain", 25000, nowdate(), "Test gain") - tracker.add_exchange_difference("Manual Entry", "TEST-002", "Loss", 15000, nowdate(), "Test loss") - - # Verify totals - self.assertEqual(tracker.total_gain_loss, 10000) # 25000 - 15000 + invoice = make_foreign_purchase_invoice() + tracker = get_tracker(invoice.name) + tracker.add_exchange_difference("Purchase Invoice", invoice.name, "Gain", 25000, nowdate(), "gain") + tracker.add_exchange_difference("Purchase Invoice", invoice.name, "Loss", 15000, nowdate(), "loss") + self.assertEqual(tracker.total_gain_loss, 10000) self.assertEqual(tracker.net_difference, 10000) - - def test_exchange_summary_method(self): - """Test the get_exchange_summary method""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - qty=10, - do_not_submit=True, - ) - pi.submit() - - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" + self.assertEqual( + frappe.db.get_value("Foreign Import Transaction", tracker.name, "net_difference"), 10000 ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - # Create Payment Entry - pe = get_payment_entry("Purchase Invoice", pi.name) - pe.source_exchange_rate = self.payment_rate - pe.paid_amount = 500 - pe.base_paid_amount = 500 * self.payment_rate - pe.insert() - pe.submit() - - # Reload tracker - tracker.reload() - - # Get exchange summary - summary = tracker.get_exchange_summary() - - # Verify summary - expected_gain = 500 * (self.payment_rate - self.original_rate) - self.assertEqual(summary["total_gain"], expected_gain) + def test_exchange_summary_method(self): + invoice = make_foreign_purchase_invoice() + make_supplier_payment(invoice.name, 500, PAYMENT_RATE) + summary = get_tracker(invoice.name).get_exchange_summary() + self.assertEqual(summary["total_gain"], 50000) self.assertEqual(summary["total_loss"], 0) - self.assertEqual(summary["payment_differences"], expected_gain) + self.assertEqual(summary["payment_differences"], 50000) self.assertEqual(summary["lcv_differences"], 0) self.assertEqual(summary["manual_entries"], 0) - self.assertEqual(summary["net_difference"], expected_gain) + self.assertEqual(summary["net_difference"], 50000) def test_no_duplicate_tracker_creation(self): - """Test that duplicate trackers are not created for same PI""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - do_not_submit=True, - ) - pi.submit() - - # Get initial tracker count - initial_count = frappe.db.count("Foreign Import Transaction", {"purchase_invoice": pi.name}) - self.assertEqual(initial_count, 1) - - # Try to trigger tracker creation again (simulate hook being called again) - from csf_tz.csftz_hooks.exchange_calculations import create_import_tracker - - create_import_tracker(pi, "on_submit") - - # Verify no duplicate tracker was created - final_count = frappe.db.count("Foreign Import Transaction", {"purchase_invoice": pi.name}) - self.assertEqual(final_count, 1) + invoice = make_foreign_purchase_invoice() + create_import_tracker(invoice, "on_submit") + self.assertEqual(frappe.db.count("Foreign Import Transaction", {"purchase_invoice": invoice.name}), 1) def test_payment_currency_mismatch_no_linking(self): - """Test that payment with different currency doesn't link to tracker""" - # Create foreign currency Purchase Invoice in USD - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, # USD - conversion_rate=self.original_rate, - rate=100, - do_not_submit=True, + invoice = make_purchase_invoice( + supplier=INR_SUPPLIER, currency="EUR", conversion_rate=80, rate=100, qty=10 ) - pi.submit() - - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - - # Create Payment Entry in different currency (EUR) - pe = get_payment_entry("Purchase Invoice", pi.name) - pe.paid_to_account_currency = "EUR" # Different currency - pe.source_exchange_rate = 2800.0 # EUR rate - pe.paid_amount = 500 - pe.base_paid_amount = 500 * 2800 - pe.insert() - pe.submit() - - # Reload tracker and verify no payment was linked - tracker.reload() - self.assertEqual(len(tracker.payments), 0) - self.assertEqual(len(tracker.exchange_differences), 0) + self.assertEqual(get_tracker(invoice.name).currency, "EUR") + payment = make_supplier_payment(invoice.name, 200, 80, bank_account=INR_BANK) + self.assertEqual(payment.paid_to_account_currency, "INR") + tracker = get_tracker(invoice.name) + self.assertEqual(tracker.payments, []) + self.assertEqual(tracker.exchange_differences, []) + self.assertFalse(frappe.db.get_value("Payment Entry", payment.name, "foreign_import_tracker")) def test_recalculate_differences_method(self): - """Test the recalculate_differences method""" - # Create foreign currency Purchase Invoice - pi = make_purchase_invoice( - supplier=self.supplier, - currency=self.currency, - conversion_rate=self.original_rate, - rate=100, - qty=10, - do_not_submit=True, - ) - pi.submit() - - # Get the created tracker - tracker_name = frappe.db.get_value( - "Foreign Import Transaction", {"purchase_invoice": pi.name}, "name" - ) - tracker = frappe.get_doc("Foreign Import Transaction", tracker_name) - - # Create Payment Entry - pe = get_payment_entry("Purchase Invoice", pi.name) - pe.source_exchange_rate = self.payment_rate - pe.paid_amount = 500 - pe.base_paid_amount = 500 * self.payment_rate - pe.insert() - pe.submit() - - # Reload tracker - tracker.reload() - initial_differences_count = len(tracker.exchange_differences) - - # Clear exchange differences manually + invoice = make_foreign_purchase_invoice() + make_supplier_payment(invoice.name, 500, PAYMENT_RATE) + tracker = get_tracker(invoice.name) tracker.exchange_differences = [] tracker.save() - - # Verify differences are cleared tracker.reload() - self.assertEqual(len(tracker.exchange_differences), 0) - - # Recalculate differences - result = tracker.recalculate_differences() - self.assertTrue(result) - - # Verify differences are recalculated + self.assertEqual(tracker.exchange_differences, []) + self.assertEqual(tracker.total_gain_loss, 0) + self.assertTrue(tracker.recalculate_differences()) tracker.reload() - self.assertEqual(len(tracker.exchange_differences), initial_differences_count) + self.assertEqual(len(tracker.exchange_differences), 1) + self.assertEqual(tracker.exchange_differences[0].amount, 50000) + self.assertEqual(tracker.total_gain_loss, 50000) From cc88b5f5da6567cd3046d931c7a0560618f7b41f Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:01:15 +0300 Subject: [PATCH 73/74] test: replace the Tra Tax Inv stub with real assertions --- .../csf_tz/doctype/tra_tax_inv/test_tra_tax_inv.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/csf_tz/csf_tz/doctype/tra_tax_inv/test_tra_tax_inv.py b/csf_tz/csf_tz/doctype/tra_tax_inv/test_tra_tax_inv.py index 6029315b..54dc5c58 100644 --- a/csf_tz/csf_tz/doctype/tra_tax_inv/test_tra_tax_inv.py +++ b/csf_tz/csf_tz/doctype/tra_tax_inv/test_tra_tax_inv.py @@ -1,22 +1,19 @@ # Copyright (c) 2025, Aakvatech and Contributors # See license.txt -import unittest - import frappe +from frappe.tests import IntegrationTestCase -class TestTRATAXInv(unittest.TestCase): +class TestTRATAXInv(IntegrationTestCase): def test_tra_tax_inv_creation(self): - """Test basic TRA Tax Inv creation""" - doc = frappe.new_doc("TRA Tax Inv") + doc = frappe.new_doc("TRA TAX Inv") doc.verification_code = "TEST123_123456" doc.type = "Sales" doc.verification_status = "Pending" + doc.insert() - # This should not raise an error - doc.validate() - + self.assertTrue(doc.name.startswith("TRA-TAX-INV-")) self.assertEqual(doc.type, "Sales") self.assertEqual(doc.verification_status, "Pending") self.assertEqual(doc.verification_code, "TEST123_123456") From 34145dd77c52e66098abc38bdebfec6b2d107faf Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 12:01:20 +0300 Subject: [PATCH 74/74] test: port the bank api shim test to IntegrationTestCase --- csf_tz/tests/test_bank_api_shim.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csf_tz/tests/test_bank_api_shim.py b/csf_tz/tests/test_bank_api_shim.py index 5b27f92f..fe2b6abb 100644 --- a/csf_tz/tests/test_bank_api_shim.py +++ b/csf_tz/tests/test_bank_api_shim.py @@ -1,12 +1,12 @@ from unittest.mock import patch import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests import IntegrationTestCase from csf_tz import bank_api -class TestBankApiShim(FrappeTestCase): +class TestBankApiShim(IntegrationTestCase): def test_throws_when_edu_tz_is_not_installed(self): with patch("frappe.get_installed_apps", return_value=["frappe", "erpnext", "csf_tz"]): with self.assertRaises(frappe.ValidationError):