Compare commits
3 Commits
042dcf8067
...
f45d66c465
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f45d66c465 | ||
|
|
f64b8f373c | ||
|
|
d51a2b104e |
@@ -14,3 +14,6 @@ from . import test_followup_controller
|
||||
from . import test_followup_adapter
|
||||
from . import test_followup_tools
|
||||
from . import test_followup_cron
|
||||
from . import test_engine_property
|
||||
from . import test_followup_full_flow
|
||||
from . import test_performance_benchmarks
|
||||
|
||||
92
fusion_accounting_followup/tests/test_engine_property.py
Normal file
92
fusion_accounting_followup/tests/test_engine_property.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Property-based invariants for follow-up services."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from hypothesis import given, settings, strategies as st, HealthCheck
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.fusion_accounting_followup.services.overdue_aging import (
|
||||
compute_aging, BUCKETS,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_followup.services.risk_scorer import score_partner
|
||||
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based')
|
||||
class TestAgingInvariants(TransactionCase):
|
||||
|
||||
@given(
|
||||
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
|
||||
amounts=st.lists(
|
||||
st.tuples(
|
||||
st.integers(min_value=-180, max_value=180),
|
||||
st.floats(min_value=0.01, max_value=100000,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
),
|
||||
min_size=0, max_size=20,
|
||||
),
|
||||
)
|
||||
@settings(max_examples=80, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_buckets_sum_equals_total(self, as_of, amounts):
|
||||
lines = [
|
||||
{'date_maturity': as_of + timedelta(days=offset),
|
||||
'amount_residual': round(amt, 2)}
|
||||
for offset, amt in amounts
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
bucket_sum = sum(b.amount for b in report.buckets)
|
||||
self.assertAlmostEqual(bucket_sum, report.total_amount, places=1)
|
||||
|
||||
@given(
|
||||
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
|
||||
days_overdue=st.integers(min_value=1, max_value=365),
|
||||
amount=st.floats(min_value=0.01, max_value=10000,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=50, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_overdue_amount_excludes_current(self, as_of, days_overdue, amount):
|
||||
lines = [
|
||||
{'date_maturity': as_of - timedelta(days=days_overdue),
|
||||
'amount_residual': round(amount, 2)},
|
||||
{'date_maturity': as_of + timedelta(days=10),
|
||||
'amount_residual': 100.0},
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
self.assertAlmostEqual(report.total_overdue_amount, round(amount, 2), places=1)
|
||||
|
||||
@given(
|
||||
invoices=st.integers(min_value=0, max_value=100),
|
||||
late=st.integers(min_value=0, max_value=100),
|
||||
days_late=st.floats(min_value=0, max_value=180,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=80, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_risk_score_in_range(self, invoices, late, days_late):
|
||||
late = min(late, invoices) if invoices > 0 else 0
|
||||
result = score_partner(
|
||||
total_invoices=invoices, paid_late_count=late,
|
||||
avg_days_late=days_late,
|
||||
longest_overdue_days=int(days_late),
|
||||
open_overdue_amount=invoices * 1000.0,
|
||||
average_invoice_amount=1000.0,
|
||||
)
|
||||
self.assertGreaterEqual(result.score, 0)
|
||||
self.assertLessEqual(result.score, 100)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'property_based')
|
||||
class TestToneInvariants(TransactionCase):
|
||||
|
||||
@given(
|
||||
sequence=st.integers(min_value=1, max_value=10),
|
||||
risk=st.integers(min_value=0, max_value=100),
|
||||
)
|
||||
@settings(max_examples=50, deadline=1000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_tone_always_in_valid_set(self, sequence, risk):
|
||||
tone = select_tone(level_sequence=sequence, risk_score=risk)
|
||||
self.assertIn(tone, ('gentle', 'firm', 'legal'))
|
||||
84
fusion_accounting_followup/tests/test_followup_full_flow.py
Normal file
84
fusion_accounting_followup/tests/test_followup_full_flow.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""End-to-end integration: scan -> escalate -> send -> reset."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestFollowupFullFlow(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.followup.engine']
|
||||
self.partner = self.env['res.partner'].create({
|
||||
'name': 'Full Flow Partner', 'email': 'flow@test.local',
|
||||
})
|
||||
for seq, name, days, tone in [(701, 'FlowReminder', 7, 'gentle'),
|
||||
(702, 'FlowWarning', 30, 'firm'),
|
||||
(703, 'FlowLegal', 60, 'legal')]:
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': name, 'sequence': seq,
|
||||
'delay_days': days, 'tone': tone,
|
||||
})
|
||||
|
||||
line = self.env['account.move.line'].search([
|
||||
('parent_state', '=', 'posted'),
|
||||
('account_id.account_type', '=', 'asset_receivable'),
|
||||
('reconciled', '=', False),
|
||||
('amount_residual', '>', 0),
|
||||
], limit=1)
|
||||
if not line:
|
||||
self.skipTest("No posted unreconciled receivable lines in test DB")
|
||||
line.write({
|
||||
'partner_id': self.partner.id,
|
||||
'date_maturity': date.today() - timedelta(days=20),
|
||||
})
|
||||
|
||||
def test_full_flow_scan_send_reset(self):
|
||||
level = self.engine.compute_followup_level(self.partner)
|
||||
self.assertTrue(level)
|
||||
self.assertGreater(level.delay_days, 0)
|
||||
|
||||
Run = self.env['fusion.followup.run']
|
||||
before = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
result = self.engine.send_followup_email(self.partner, force=True)
|
||||
after = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertGreater(after, before)
|
||||
self.assertIn(result['status'], ('sent', 'manual_review'))
|
||||
|
||||
self.engine.pause_followup(self.partner,
|
||||
until_date=date.today() + timedelta(days=14))
|
||||
result_paused = self.engine.send_followup_email(self.partner)
|
||||
self.assertTrue(result_paused['status'].startswith('paused'))
|
||||
|
||||
self.engine.reset_followup(self.partner)
|
||||
self.partner.invalidate_recordset(['fusion_followup_status'])
|
||||
self.assertEqual(self.partner.fusion_followup_status, 'no_action')
|
||||
|
||||
def test_escalate_advances_to_next_level(self):
|
||||
Level = self.env['fusion.followup.level']
|
||||
level1 = Level.search([('sequence', '=', 701)], limit=1)
|
||||
self.engine.send_followup_email(self.partner, level=level1, force=True)
|
||||
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
|
||||
result = self.engine.escalate_to_next_level(self.partner)
|
||||
self.assertIn('partner_id', result)
|
||||
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
|
||||
if self.partner.fusion_followup_last_level_id:
|
||||
self.assertGreaterEqual(self.partner.fusion_followup_last_level_id.sequence, 702)
|
||||
|
||||
def test_text_cache_reused_on_repeat(self):
|
||||
Cache = self.env['fusion.followup.text.cache']
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
after_first = Cache.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
after_second = Cache.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertEqual(after_first, after_second)
|
||||
|
||||
def test_history_records_each_send(self):
|
||||
Run = self.env['fusion.followup.run']
|
||||
before = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
self.engine.send_followup_email(self.partner, force=True)
|
||||
after = Run.search_count([('partner_id', '=', self.partner.id)])
|
||||
self.assertEqual(after - before, 2)
|
||||
100
fusion_accounting_followup/tests/test_performance_benchmarks.py
Normal file
100
fusion_accounting_followup/tests/test_performance_benchmarks.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Performance benchmarks tagged 'benchmark'."""
|
||||
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo.tests.common import HttpCase, TransactionCase, new_test_user
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
def _percentile(samples, p):
|
||||
if len(samples) <= 1:
|
||||
return samples[0] if samples else 0
|
||||
sorted_s = sorted(samples)
|
||||
idx = int(len(sorted_s) * p / 100)
|
||||
return sorted_s[min(idx, len(sorted_s) - 1)]
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestEngineBenchmarks(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.followup.engine']
|
||||
for seq, name, days, tone in [(601, 'PerfReminder', 7, 'gentle'),
|
||||
(602, 'PerfWarning', 30, 'firm'),
|
||||
(603, 'PerfLegal', 60, 'legal')]:
|
||||
self.env['fusion.followup.level'].create({
|
||||
'name': name, 'sequence': seq,
|
||||
'delay_days': days, 'tone': tone,
|
||||
})
|
||||
|
||||
def test_get_overdue_p95(self):
|
||||
partner = self.env['res.partner'].create({'name': 'PerfPartner'})
|
||||
timings = []
|
||||
for _ in range(10):
|
||||
start = time.perf_counter()
|
||||
self.engine.get_overdue_for_partner(partner)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"get_overdue_for_partner: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <100ms)")
|
||||
self.assertLess(p95, 1000, f"way over budget: {msg}")
|
||||
|
||||
def test_compute_followup_level_p95(self):
|
||||
partner = self.env['res.partner'].create({'name': 'CompLevelPerf'})
|
||||
timings = []
|
||||
for _ in range(10):
|
||||
start = time.perf_counter()
|
||||
self.engine.compute_followup_level(partner)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"compute_followup_level: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <50ms)")
|
||||
self.assertLess(p95, 500)
|
||||
|
||||
def test_send_followup_p95(self):
|
||||
partner = self.env['res.partner'].create({
|
||||
'name': 'SendPerf', 'email': 'sp@test.local',
|
||||
})
|
||||
timings = []
|
||||
for _ in range(5):
|
||||
start = time.perf_counter()
|
||||
self.engine.send_followup_email(partner, force=True)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"send_followup_email (no overdue): median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <200ms)")
|
||||
self.assertLess(p95, 2000)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestControllerBenchmarks(HttpCase):
|
||||
|
||||
def test_list_overdue_p95(self):
|
||||
new_test_user(self.env, login='fu_perf',
|
||||
groups='base.group_user,account.group_account_invoice,base.group_partner_manager')
|
||||
for i in range(20):
|
||||
self.env['res.partner'].create({'name': f'PerfP{i}'})
|
||||
self.authenticate('fu_perf', 'fu_perf')
|
||||
timings = []
|
||||
for _ in range(5):
|
||||
start = time.perf_counter()
|
||||
response = self.url_open(
|
||||
'/fusion/followup/list_overdue',
|
||||
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'id': 1,
|
||||
'params': {'company_id': self.env.company.id}}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
p95 = _percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"controller.list_overdue: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <500ms)")
|
||||
self.assertLess(p95, 5000)
|
||||
Reference in New Issue
Block a user