From fce748b89c068f7d2dab7664790372e4db4e7871 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 11:11:30 -0400 Subject: [PATCH] test(fusion_accounting_bank_rec): integration tests for engine end-to-end flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests engine behavior using factories (Task 18) instead of SQL fixtures. Covers simple match, partial chain, multi-invoice batch, suggest-then- accept flow, unreconcile reversal, and edge cases. Two tests are intentionally failing — they expose real engine bugs that should be fixed in a follow-up: - TestReconcilePartialChain.test_partial_reconcile_leaves_residual: reconcile_one() builds counterpart vals using the full invoice residual, which leaves the bank move unbalanced when bank amount is smaller than the invoice (UserError: entry not balanced). - TestUnreconcile.test_unreconcile_removes_partial: unreconcile() unlinks partial.reconcile rows but does not restore the suspense line on the bank move, so account.bank.statement.line.is_reconciled remains True after reversal. Made-with: Cursor --- fusion_accounting_bank_rec/tests/__init__.py | 1 + .../test_reconcile_engine_integration.py | 201 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 fusion_accounting_bank_rec/tests/test_reconcile_engine_integration.py diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 480ab5d9..066ea966 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_confidence_scoring from . import test_reconcile_engine_unit from . import test_reconcile_engine_property from . import test_factories +from . import test_reconcile_engine_integration diff --git a/fusion_accounting_bank_rec/tests/test_reconcile_engine_integration.py b/fusion_accounting_bank_rec/tests/test_reconcile_engine_integration.py new file mode 100644 index 00000000..0dbad335 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_reconcile_engine_integration.py @@ -0,0 +1,201 @@ +"""Integration tests for the reconcile engine. + +These tests use the test factories (_factories.py) to set up realistic +bank-line + invoice scenarios, then call engine methods and assert the +account.partial.reconcile rows produced have the right shape. + +Tests cover: +- Simple 1:1 match (bank line == one invoice) +- Partial chain (one bank line < invoice amount) +- Multi-invoice consolidation (one bank line == sum of N invoices) +- Auto-strategy batch (mix of matchable and unmatchable lines) +- Suggest-then-accept flow +- Unreconcile (reverse a reconciliation) +""" + +from datetime import date, timedelta +from odoo.tests.common import TransactionCase, tagged + +from . import _factories as f + + +@tagged('post_install', '-at_install', 'integration') +class TestReconcileSimpleMatch(TransactionCase): + """The most common scenario: 1 bank line matched against 1 invoice exact.""" + + def test_simple_match_creates_partial_reconcile(self): + bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00) + + result = self.env['fusion.reconcile.engine'].reconcile_one( + bank_line, against_lines=recv_lines) + + self.assertGreater(len(result['partial_ids']), 0) + partial = self.env['account.partial.reconcile'].browse(result['partial_ids']) + self.assertAlmostEqual(sum(partial.mapped('amount')), 100.00, places=2) + + def test_simple_match_marks_line_reconciled(self): + bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=250.00) + self.env['fusion.reconcile.engine'].reconcile_one( + bank_line, against_lines=recv_lines) + bank_line.invalidate_recordset(['is_reconciled']) + self.assertTrue(bank_line.is_reconciled) + + def test_simple_match_records_precedent(self): + bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=500.00) + partner = bank_line.partner_id + Precedent = self.env['fusion.reconcile.precedent'] + before = Precedent.search_count([('partner_id', '=', partner.id)]) + + self.env['fusion.reconcile.engine'].reconcile_one( + bank_line, against_lines=recv_lines) + + after = Precedent.search_count([('partner_id', '=', partner.id)]) + self.assertEqual(after, before + 1, "Engine should record one precedent per reconcile") + + +@tagged('post_install', '-at_install', 'integration') +class TestReconcilePartialChain(TransactionCase): + """Bank line amount < invoice amount -> partial reconcile, residual remains.""" + + def test_partial_reconcile_leaves_residual(self): + partner = self.env['res.partner'].create({'name': 'Partial Partner'}) + invoice = f.make_invoice(self.env, partner=partner, amount=300.00) + recv_lines = invoice.line_ids.filtered( + lambda l: l.account_id.account_type == 'asset_receivable') + + bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner) + result = self.env['fusion.reconcile.engine'].reconcile_one( + bank_line, against_lines=recv_lines) + + self.assertGreater(len(result['partial_ids']), 0) + invoice.invalidate_recordset(['payment_state', 'amount_residual']) + self.assertAlmostEqual(invoice.amount_residual, 200.00, places=2) + + +@tagged('post_install', '-at_install', 'integration') +class TestReconcileBatch(TransactionCase): + """Bulk reconcile: mix of matchable and unmatchable lines.""" + + def test_batch_reconciles_matchable_lines_only(self): + partner = self.env['res.partner'].create({'name': 'Batch Partner'}) + # Share one journal/statement to avoid duplicate-code conflicts + # when creating multiple bank lines in the same test transaction. + shared_journal = f.make_bank_journal(self.env, name='Batch Bank', code='BBNK') + shared_statement = f.make_bank_statement(self.env, journal=shared_journal) + pairs = [] + for amount in [100.00, 200.00, 300.00]: + invoice = f.make_invoice(self.env, partner=partner, amount=amount) + recv_lines = invoice.line_ids.filtered( + lambda l: l.account_id.account_type == 'asset_receivable') + bank_line = f.make_bank_line( + self.env, statement=shared_statement, amount=amount, + partner=partner) + pairs.append((bank_line, recv_lines)) + + orphan_line = f.make_bank_line( + self.env, statement=shared_statement, amount=999.99, partner=partner) + + all_lines = self.env['account.bank.statement.line'].browse( + [p[0].id for p in pairs] + [orphan_line.id]) + + result = self.env['fusion.reconcile.engine'].reconcile_batch( + all_lines, strategy='auto') + + self.assertEqual(result['reconciled_count'], 3) + self.assertGreaterEqual(result['skipped'], 1) + self.assertEqual(len(result['errors']), 0) + + def test_batch_handles_empty_recordset(self): + empty = self.env['account.bank.statement.line'] + result = self.env['fusion.reconcile.engine'].reconcile_batch(empty) + self.assertEqual(result['reconciled_count'], 0) + + +@tagged('post_install', '-at_install', 'integration') +class TestSuggestThenAccept(TransactionCase): + """Full flow: suggest_matches creates suggestions; accept_suggestion reconciles.""" + + def test_suggest_then_accept(self): + partner = self.env['res.partner'].create({'name': 'Suggest Then Accept'}) + invoice = f.make_invoice(self.env, partner=partner, amount=750.00) + bank_line = f.make_bank_line(self.env, amount=750.00, partner=partner, + memo='Test suggest accept') + + suggestions = self.env['fusion.reconcile.engine'].suggest_matches( + bank_line, limit_per_line=3) + + self.assertIn(bank_line.id, suggestions) + self.assertGreater(len(suggestions[bank_line.id]), 0, + "Engine should suggest at least one candidate for matching invoice") + + top_suggestion_id = suggestions[bank_line.id][0]['id'] + sug = self.env['fusion.reconcile.suggestion'].browse(top_suggestion_id) + result = self.env['fusion.reconcile.engine'].accept_suggestion(sug) + + self.assertGreater(len(result['partial_ids']), 0) + sug.invalidate_recordset(['state', 'accepted_at', 'accepted_by']) + self.assertEqual(sug.state, 'accepted') + self.assertTrue(sug.accepted_at) + bank_line.invalidate_recordset(['is_reconciled']) + self.assertTrue(bank_line.is_reconciled) + + def test_suggest_supersedes_prior_pending(self): + partner = self.env['res.partner'].create({'name': 'Supersede Test'}) + bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner) + invoice = f.make_invoice(self.env, partner=partner, amount=100.00) + + self.env['fusion.reconcile.engine'].suggest_matches(bank_line) + first_pending = self.env['fusion.reconcile.suggestion'].search([ + ('statement_line_id', '=', bank_line.id), + ('state', '=', 'pending'), + ]) + self.assertGreater(len(first_pending), 0) + + self.env['fusion.reconcile.engine'].suggest_matches(bank_line) + first_pending.invalidate_recordset(['state']) + for s in first_pending: + self.assertEqual(s.state, 'superseded') + + +@tagged('post_install', '-at_install', 'integration') +class TestUnreconcile(TransactionCase): + """Reverse a reconciliation.""" + + def test_unreconcile_removes_partial(self): + bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00) + result = self.env['fusion.reconcile.engine'].reconcile_one( + bank_line, against_lines=recv_lines) + partials = self.env['account.partial.reconcile'].browse(result['partial_ids']) + self.assertGreater(len(partials), 0) + + unrec_result = self.env['fusion.reconcile.engine'].unreconcile(partials) + + self.assertGreater(len(unrec_result['unreconciled_line_ids']), 0) + self.assertFalse(partials.exists()) + bank_line.invalidate_recordset(['is_reconciled']) + self.assertFalse(bank_line.is_reconciled) + + +@tagged('post_install', '-at_install', 'integration') +class TestEngineEdgeCases(TransactionCase): + """Edge cases that came up during engine implementation.""" + + def test_reconcile_validates_line_exists(self): + from odoo.exceptions import ValidationError + with self.assertRaises(ValidationError): + self.env['fusion.reconcile.engine'].reconcile_one( + self.env['account.bank.statement.line'], + against_lines=self.env['account.move.line']) + + def test_already_reconciled_line_skipped_in_batch(self): + partner = self.env['res.partner'].create({'name': 'Already Reconciled'}) + bank_line, recv_lines = f.make_reconcileable_pair( + self.env, amount=50.00, partner=partner) + + self.env['fusion.reconcile.engine'].reconcile_one( + bank_line, against_lines=recv_lines) + bank_line.invalidate_recordset(['is_reconciled']) + self.assertTrue(bank_line.is_reconciled) + + result = self.env['fusion.reconcile.engine'].reconcile_batch(bank_line) + self.assertGreater(result['skipped'], 0)