This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -0,0 +1,3 @@
from . import test_post_migration
from . import test_data_adapters
from . import test_llm_provider_contract

View File

@@ -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 ===')

View 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 ===')

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)