changes
This commit is contained in:
3
fusion_accounting/fusion_accounting_ai/tests/__init__.py
Normal file
3
fusion_accounting/fusion_accounting_ai/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import test_post_migration
|
||||
from . import test_data_adapters
|
||||
from . import test_llm_provider_contract
|
||||
@@ -0,0 +1,64 @@
|
||||
import anthropic
|
||||
import json
|
||||
import sys
|
||||
|
||||
api_key = sys.argv[1]
|
||||
model = sys.argv[2] if len(sys.argv) > 2 else 'claude-sonnet-4-6'
|
||||
print(f'API Key: {api_key[:12]}...{api_key[-4:]}')
|
||||
print(f'Model: {model}')
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
print()
|
||||
print('--- Test 1: Basic API Call ---')
|
||||
try:
|
||||
r = client.messages.create(model=model, max_tokens=100, messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}])
|
||||
print(f'OK: {r.content[0].text}')
|
||||
print(f'Tokens: {r.usage.input_tokens} in, {r.usage.output_tokens} out')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print('--- Test 2: Tool Calling ---')
|
||||
try:
|
||||
tools = [{'name': 'get_account_balance', 'description': 'Get balance of an account by code', 'input_schema': {'type': 'object', 'properties': {'account_code': {'type': 'string', 'description': 'Account code'}}, 'required': ['account_code']}}]
|
||||
r = client.messages.create(model=model, max_tokens=300, system='You are an accounting AI. Always use tools to look up data before answering.', messages=[{'role': 'user', 'content': 'Look up the balance on account 2005.'}], tools=tools)
|
||||
print(f'Stop reason: {r.stop_reason}')
|
||||
tool_id = None
|
||||
for b in r.content:
|
||||
if b.type == 'text':
|
||||
print(f'Text: {b.text}')
|
||||
elif b.type == 'tool_use':
|
||||
print(f'TOOL CALL: {b.name}({json.dumps(b.input)}) id={b.id}')
|
||||
tool_id = b.id
|
||||
if r.stop_reason == 'tool_use':
|
||||
print('RESULT: Tool calling WORKING')
|
||||
else:
|
||||
print('RESULT: No tool call (model answered directly)')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print('--- Test 3: Tool Result Round-Trip ---')
|
||||
try:
|
||||
if not tool_id:
|
||||
tool_id = 'toolu_test123'
|
||||
msgs = [
|
||||
{'role': 'user', 'content': 'Look up account 2005 balance.'},
|
||||
{'role': 'assistant', 'content': [{'type': 'tool_use', 'id': tool_id, 'name': 'get_account_balance', 'input': {'account_code': '2005'}}]},
|
||||
{'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': tool_id, 'content': json.dumps({'balance': -15234.56, 'name': 'HST Collected'})}]}
|
||||
]
|
||||
r2 = client.messages.create(model=model, max_tokens=200, system='You are an accounting AI. Report findings in Canadian dollars.', messages=msgs, tools=tools)
|
||||
for b in r2.content:
|
||||
if b.type == 'text':
|
||||
print(f'AI: {b.text}')
|
||||
print(f'Tokens: {r2.usage.input_tokens} in, {r2.usage.output_tokens} out')
|
||||
print('RESULT: Multi-turn tool flow WORKING')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print('=== ALL TESTS PASSED ===')
|
||||
107
fusion_accounting/fusion_accounting_ai/tests/test_claude_api.py
Normal file
107
fusion_accounting/fusion_accounting_ai/tests/test_claude_api.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import anthropic
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def get_db_param(key):
|
||||
result = subprocess.run(
|
||||
['docker', 'exec', 'odoo-dev-db', 'psql', '-U', 'odoo', '-d', 'westin-v19', '-t', '-A', '-c',
|
||||
f"SELECT value FROM ir_config_parameter WHERE key = '{key}'"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
api_key = get_db_param('fusion_accounting.anthropic_api_key')
|
||||
if not api_key:
|
||||
print('ERROR: No API key found in database')
|
||||
sys.exit(1)
|
||||
print(f'API Key found: {api_key[:12]}...{api_key[-4:]}')
|
||||
|
||||
model = get_db_param('fusion_accounting.claude_model') or 'claude-sonnet-4-6'
|
||||
print(f'Model: {model}')
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
# Test 1: Basic API call
|
||||
print('\n--- Test 1: Basic API Call ---')
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=100,
|
||||
messages=[{'role': 'user', 'content': 'Say hello in one sentence.'}]
|
||||
)
|
||||
print(f'Status: OK')
|
||||
print(f'Response: {response.content[0].text}')
|
||||
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Tool calling
|
||||
print('\n--- Test 2: Tool Calling ---')
|
||||
try:
|
||||
tools = [{
|
||||
'name': 'get_account_balance',
|
||||
'description': 'Get the balance of an accounting account by code',
|
||||
'input_schema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'account_code': {'type': 'string', 'description': 'Account code like 1000, 2005'},
|
||||
},
|
||||
'required': ['account_code']
|
||||
}
|
||||
}]
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=300,
|
||||
system='You are an accounting assistant. Use tools to look up data.',
|
||||
messages=[{'role': 'user', 'content': 'What is the balance on account 2005 (HST Collected)?'}],
|
||||
tools=tools,
|
||||
)
|
||||
print(f'Stop reason: {response.stop_reason}')
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
print(f'Text: {block.text}')
|
||||
elif block.type == 'tool_use':
|
||||
print(f'Tool call: {block.name}({json.dumps(block.input)})')
|
||||
print(f'Tool ID: {block.id}')
|
||||
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
|
||||
if response.stop_reason == 'tool_use':
|
||||
print('Tool calling: WORKING')
|
||||
else:
|
||||
print('Tool calling: Model responded with text (functional but did not use tool)')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Multi-turn with tool result
|
||||
print('\n--- Test 3: Tool Result Round-Trip ---')
|
||||
try:
|
||||
messages = [
|
||||
{'role': 'user', 'content': 'What is the HST balance on account 2005?'},
|
||||
{'role': 'assistant', 'content': [
|
||||
{'type': 'tool_use', 'id': 'test_123', 'name': 'get_account_balance',
|
||||
'input': {'account_code': '2005'}}
|
||||
]},
|
||||
{'role': 'user', 'content': [
|
||||
{'type': 'tool_result', 'tool_use_id': 'test_123',
|
||||
'content': json.dumps({'balance': -15234.56, 'name': 'HST Collected'})}
|
||||
]}
|
||||
]
|
||||
response = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=200,
|
||||
system='You are an accounting assistant. Report findings concisely in Canadian dollars.',
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
)
|
||||
for block in response.content:
|
||||
if block.type == 'text':
|
||||
print(f'AI Response: {block.text}')
|
||||
print(f'Tokens: {response.usage.input_tokens} in, {response.usage.output_tokens} out')
|
||||
print('Multi-turn tool flow: WORKING')
|
||||
except Exception as e:
|
||||
print(f'FAILED: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
print('\n=== ALL TESTS PASSED ===')
|
||||
@@ -0,0 +1,150 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters.base import (
|
||||
DataAdapter, AdapterMode,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters import get_adapter
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestDataAdapterBase(TransactionCase):
|
||||
"""Verify the data adapter base class chooses the correct backend."""
|
||||
|
||||
def test_adapter_mode_pure_community(self):
|
||||
"""With no fusion native and no Enterprise, adapter selects COMMUNITY."""
|
||||
adapter = DataAdapter(self.env)
|
||||
mode = adapter._select_mode(
|
||||
fusion_native_model='fusion.bank.rec.widget',
|
||||
enterprise_module='account_accountant',
|
||||
)
|
||||
self.assertIn(mode, (AdapterMode.FUSION, AdapterMode.ENTERPRISE, AdapterMode.COMMUNITY))
|
||||
|
||||
def test_adapter_falls_back_when_fusion_model_missing(self):
|
||||
"""Adapter must not error when the fusion native model isn't loaded."""
|
||||
adapter = DataAdapter(self.env)
|
||||
mode = adapter._select_mode(
|
||||
fusion_native_model='fusion.never.exists',
|
||||
enterprise_module='also_does_not_exist',
|
||||
)
|
||||
self.assertEqual(mode, AdapterMode.COMMUNITY)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestBankRecAdapter(TransactionCase):
|
||||
"""Verify the bank-rec adapter returns rows in any install profile."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.journal = self.env['account.journal'].create({
|
||||
'name': 'Test Bank',
|
||||
'type': 'bank',
|
||||
'code': 'TBNK',
|
||||
})
|
||||
self.statement = self.env['account.bank.statement'].create({
|
||||
'name': 'Test Statement',
|
||||
'journal_id': self.journal.id,
|
||||
})
|
||||
self.line = self.env['account.bank.statement.line'].create({
|
||||
'statement_id': self.statement.id,
|
||||
'journal_id': self.journal.id,
|
||||
'date': '2026-04-18',
|
||||
'payment_ref': 'Test Payment',
|
||||
'amount': 100.0,
|
||||
})
|
||||
|
||||
def test_list_unreconciled_returns_our_test_line(self):
|
||||
"""The adapter should find the unreconciled line we just created."""
|
||||
adapter = get_adapter(self.env, 'bank_rec')
|
||||
rows = adapter.list_unreconciled(journal_id=self.journal.id, limit=10)
|
||||
ids = [r['id'] for r in rows]
|
||||
self.assertIn(self.line.id, ids,
|
||||
f"Expected line {self.line.id} in unreconciled list, got: {ids}")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReportsAdapter(TransactionCase):
|
||||
"""Verify the reports adapter computes a trial-balance-shaped result."""
|
||||
|
||||
def test_trial_balance_returns_rows_in_pure_community(self):
|
||||
adapter = get_adapter(self.env, 'reports')
|
||||
result = adapter.trial_balance()
|
||||
self.assertIsInstance(result, list)
|
||||
for row in result:
|
||||
self.assertIn('account_id', row)
|
||||
self.assertIn('balance', row)
|
||||
|
||||
def test_run_report_returns_lines_or_error_dict(self):
|
||||
"""run_report() must always return either an Enterprise-shaped
|
||||
{'report_name', 'lines'} dict or an {'error': ...} dict — never raise."""
|
||||
adapter = get_adapter(self.env, 'reports')
|
||||
result = adapter.run_report(ref_id='account_reports.profit_and_loss')
|
||||
self.assertIsInstance(result, dict)
|
||||
# Either a report_name+lines response or an error — both valid
|
||||
self.assertTrue(
|
||||
('lines' in result and 'report_name' in result) or 'error' in result,
|
||||
f"Unexpected result shape: {result!r}",
|
||||
)
|
||||
|
||||
def test_run_report_with_unknown_ref_returns_error(self):
|
||||
adapter = get_adapter(self.env, 'reports')
|
||||
result = adapter.run_report(ref_id='nonexistent.report.xml_id')
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_export_report_returns_dict(self):
|
||||
adapter = get_adapter(self.env, 'reports')
|
||||
result = adapter.export_report(
|
||||
ref_id='account_reports.profit_and_loss', fmt='pdf',
|
||||
)
|
||||
self.assertIsInstance(result, dict)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFollowupAdapter(TransactionCase):
|
||||
def test_overdue_invoices_returns_list(self):
|
||||
adapter = get_adapter(self.env, 'followup')
|
||||
rows = adapter.overdue_invoices(days_overdue=30)
|
||||
self.assertIsInstance(rows, list)
|
||||
|
||||
def test_overdue_invoices_row_has_contact_fields(self):
|
||||
"""The enriched shape must include email, phone, and amount_total so
|
||||
the accounts_receivable tool wrapper can render them."""
|
||||
adapter = get_adapter(self.env, 'followup')
|
||||
rows = adapter.overdue_invoices(days_overdue=30, limit=5)
|
||||
for row in rows:
|
||||
for key in (
|
||||
'id', 'name', 'partner_id', 'partner_name',
|
||||
'partner_email', 'partner_phone',
|
||||
'invoice_date_due', 'amount_total', 'amount_residual',
|
||||
'days_overdue',
|
||||
):
|
||||
self.assertIn(key, row, f"Missing key {key!r} in overdue row")
|
||||
|
||||
def test_aged_receivables_returns_bucket_shape(self):
|
||||
adapter = get_adapter(self.env, 'followup')
|
||||
result = adapter.aged_receivables(company_id=self.env.company.id)
|
||||
self.assertIn('total', result)
|
||||
self.assertIn('buckets', result)
|
||||
self.assertIn('line_count', result)
|
||||
for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'):
|
||||
self.assertIn(bucket, result['buckets'])
|
||||
|
||||
def test_aged_payables_returns_bucket_shape(self):
|
||||
adapter = get_adapter(self.env, 'followup')
|
||||
result = adapter.aged_payables(company_id=self.env.company.id)
|
||||
self.assertIn('total', result)
|
||||
self.assertIn('buckets', result)
|
||||
self.assertIn('line_count', result)
|
||||
for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'):
|
||||
self.assertIn(bucket, result['buckets'])
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAssetsAdapter(TransactionCase):
|
||||
def test_list_assets_returns_dict_with_assets(self):
|
||||
# Phase 3 (fusion_accounting_assets) wired list_assets to return
|
||||
# {count, total, assets} — consistent with bank_rec.list_unreconciled etc.
|
||||
adapter = get_adapter(self.env, 'assets')
|
||||
rows = adapter.list_assets()
|
||||
self.assertIsInstance(rows, dict)
|
||||
self.assertIn('assets', rows)
|
||||
self.assertIsInstance(rows['assets'], list)
|
||||
@@ -0,0 +1,45 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLLMProviderContract(TransactionCase):
|
||||
"""Every LLM adapter must satisfy the LLMProvider contract."""
|
||||
|
||||
def test_base_class_defines_capability_attrs(self):
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_streaming'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'max_context_tokens'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_embeddings'))
|
||||
|
||||
def test_openai_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
self.assertTrue(issubclass(OpenAIAdapter, LLMProvider))
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_claude_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
self.assertTrue(issubclass(ClaudeAdapter, LLMProvider))
|
||||
adapter = ClaudeAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_openai_adapter_uses_configurable_base_url(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_base_url', 'http://localhost:1234/v1')
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_api_key', 'lm-studio-test-key')
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertEqual(str(adapter.client.base_url).rstrip('/'),
|
||||
'http://localhost:1234/v1')
|
||||
|
||||
def test_openai_adapter_default_base_url_when_unset(self):
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', '=', 'fusion_accounting.openai_base_url')
|
||||
]).unlink()
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIn('api.openai.com', str(adapter.client.base_url))
|
||||
@@ -0,0 +1,34 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPostMigration(TransactionCase):
|
||||
"""Verify ir_model_data ownership transferred from fusion_accounting to fusion_accounting_ai."""
|
||||
|
||||
def test_no_orphan_ir_model_data_in_old_module(self):
|
||||
"""No fusion-related model/view/data record should still claim module='fusion_accounting'.
|
||||
|
||||
After Phase 0, fusion_accounting is the meta-module and owns no records.
|
||||
Every fusion.* model/view/data record should be owned by a sub-module
|
||||
(fusion_accounting_ai, fusion_accounting_core, fusion_accounting_migration).
|
||||
"""
|
||||
orphans = self.env['ir.model.data'].search([
|
||||
('module', '=', 'fusion_accounting'),
|
||||
('name', 'like', '%'),
|
||||
])
|
||||
# The meta-module legitimately may own zero records. Anything found here
|
||||
# is an orphan from the pre-Phase-0 layout.
|
||||
self.assertFalse(
|
||||
orphans,
|
||||
f"Found {len(orphans)} ir_model_data rows still owned by fusion_accounting "
|
||||
f"(should be owned by sub-modules). Examples: "
|
||||
f"{[(r.module, r.name) for r in orphans[:5]]}"
|
||||
)
|
||||
|
||||
def test_known_xml_ids_resolve_via_new_module(self):
|
||||
"""Spot-check that key xml-ids are reachable under the new module name."""
|
||||
# Sessions model
|
||||
ref = self.env.ref('fusion_accounting_ai.model_fusion_accounting_session', raise_if_not_found=False)
|
||||
self.assertTrue(ref, "fusion_accounting_ai.model_fusion_accounting_session should resolve")
|
||||
# Security group
|
||||
# (this lives in _core after Task 12 — adapt assertion when Task 12 completes)
|
||||
Reference in New Issue
Block a user