diff --git a/fusion_accounting_bank_rec/__manifest__.py b/fusion_accounting_bank_rec/__manifest__.py index 66e92b09..38f2886f 100644 --- a/fusion_accounting_bank_rec/__manifest__.py +++ b/fusion_accounting_bank_rec/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting — Bank Reconciliation', - 'version': '19.0.1.0.7', + 'version': '19.0.1.0.8', 'category': 'Accounting/Accounting', 'sequence': 28, 'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.', diff --git a/fusion_accounting_bank_rec/controllers/__init__.py b/fusion_accounting_bank_rec/controllers/__init__.py index e69de29b..98384940 100644 --- a/fusion_accounting_bank_rec/controllers/__init__.py +++ b/fusion_accounting_bank_rec/controllers/__init__.py @@ -0,0 +1 @@ +from . import bank_rec_controller diff --git a/fusion_accounting_bank_rec/controllers/bank_rec_controller.py b/fusion_accounting_bank_rec/controllers/bank_rec_controller.py new file mode 100644 index 00000000..44a9af20 --- /dev/null +++ b/fusion_accounting_bank_rec/controllers/bank_rec_controller.py @@ -0,0 +1,325 @@ +"""HTTP controller: 10 JSON-RPC endpoints for the OWL bank-rec widget. + +All endpoints route through ``BankRecAdapter`` (which lives in +``fusion_accounting_ai`` and already encapsulates fusion / enterprise / +community routing) or directly through ``fusion.reconcile.engine`` for +methods the adapter does not yet expose. The controller never touches +``account.partial.reconcile`` directly. + +V19: uses ``@route(type='jsonrpc')``, the V19-blessed replacement for the +deprecated ``type='json'`` (Odoo 19 logs a deprecation warning if you +still use ``json``). +""" + +import logging + +from odoo import _, http +from odoo.exceptions import ValidationError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _adapter(): + """Resolve the bank-rec data adapter from fusion_accounting_ai.""" + from odoo.addons.fusion_accounting_ai.services.data_adapters import ( + get_adapter, + ) + return get_adapter(request.env, 'bank_rec') + + +class FusionBankRecController(http.Controller): + """JSON-RPC surface consumed by the OWL bank-reconciliation widget. + + All routes are ``auth='user'`` -- anonymous traffic is rejected by + Odoo's HTTP layer before reaching the handler. + """ + + # ------------------------------------------------------------------ + # 1. get_state -- initial widget bootstrap + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/get_state', type='jsonrpc', auth='user') + def get_state(self, journal_id, company_id): + """Return the journal summary that seeds the kanban widget.""" + Journal = request.env['account.journal'] + Line = request.env['account.bank.statement.line'] + journal = Journal.browse(int(journal_id)) + if not journal.exists(): + raise ValidationError(_("Journal %s not found") % journal_id) + company_id = int(company_id) if company_id else request.env.company.id + unreconciled_lines = Line.search([ + ('journal_id', '=', journal.id), + ('is_reconciled', '=', False), + ('company_id', '=', company_id), + ]) + total_amount = sum(abs(l.amount) for l in unreconciled_lines) + last_stmt = request.env['account.bank.statement'].search( + [('journal_id', '=', journal.id)], + order='date desc', limit=1) + currency = journal.currency_id or journal.company_id.currency_id + return { + 'journal': { + 'id': journal.id, + 'name': journal.name, + 'currency_code': currency.name, + }, + 'unreconciled_count': len(unreconciled_lines), + 'total_pending_amount': total_amount, + 'last_statement_date': str(last_stmt.date) if last_stmt and last_stmt.date else None, + } + + # ------------------------------------------------------------------ + # 2. list_unreconciled -- paginated, fusion-enriched + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/list_unreconciled', type='jsonrpc', auth='user') + def list_unreconciled(self, journal_id, limit=50, offset=0, + company_id=None, date_from=None, date_to=None, + min_amount=None): + """Return enriched, paginated unreconciled bank lines.""" + limit = int(limit) + offset = int(offset) + company_id = (int(company_id) if company_id + else request.env.company.id) + # The adapter doesn't take an offset; over-fetch and slice. + rows = _adapter().list_unreconciled( + journal_id=int(journal_id), + limit=limit + offset, + company_id=company_id, + date_from=date_from, + date_to=date_to, + min_amount=min_amount, + ) + sliced = rows[offset:offset + limit] + Line = request.env['account.bank.statement.line'] + domain = [ + ('journal_id', '=', int(journal_id)), + ('is_reconciled', '=', False), + ('company_id', '=', company_id), + ] + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + if min_amount is not None: + domain.append(('amount', '>=', float(min_amount))) + total = Line.search_count(domain) + return { + 'count': len(sliced), + 'total': total, + 'lines': sliced, + } + + # ------------------------------------------------------------------ + # 3. get_line_detail -- one line + suggestions + attachments + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/get_line_detail', type='jsonrpc', auth='user') + def get_line_detail(self, statement_line_id): + """Return full detail for one line including pending suggestions.""" + Line = request.env['account.bank.statement.line'] + line = Line.browse(int(statement_line_id)) + if not line.exists(): + raise ValidationError( + _("Statement line %s not found") % statement_line_id) + Sug = request.env['fusion.reconcile.suggestion'] + suggestions = Sug.search([ + ('statement_line_id', '=', line.id), + ('state', '=', 'pending'), + ], order='confidence desc, rank asc') + Att = request.env['ir.attachment'] + attachments = Att.search([ + ('res_model', '=', 'account.move'), + ('res_id', '=', line.move_id.id), + ]) if line.move_id else Att.browse() + currency = line.currency_id or line.company_id.currency_id + return { + 'line': { + 'id': line.id, + 'date': str(line.date) if line.date else None, + 'payment_ref': line.payment_ref or '', + 'amount': line.amount, + 'partner_id': line.partner_id.id if line.partner_id else None, + 'partner_name': line.partner_id.name if line.partner_id else None, + 'currency_id': currency.id, + 'currency_code': currency.name, + 'journal_id': line.journal_id.id, + 'journal_name': line.journal_id.name, + 'is_reconciled': line.is_reconciled, + }, + 'suggestions': [{ + 'id': s.id, + 'candidate_ids': s.proposed_move_line_ids.ids, + 'confidence': s.confidence, + 'rank': s.rank, + 'reasoning': s.reasoning or '', + 'scores': { + 'amount_match': s.score_amount_match, + 'partner_pattern': s.score_partner_pattern, + 'precedent_similarity': s.score_precedent_similarity, + 'ai_rerank': s.score_ai_rerank, + }, + } for s in suggestions], + 'attachments': [{ + 'id': a.id, + 'name': a.name, + 'mimetype': a.mimetype, + } for a in attachments], + } + + # ------------------------------------------------------------------ + # 4. suggest_matches -- lazy AI suggest for a line + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/suggest_matches', type='jsonrpc', auth='user') + def suggest_matches(self, statement_line_ids, limit_per_line=3): + """Trigger AI suggest for one or more statement lines.""" + ids = [int(i) for i in (statement_line_ids or [])] + result = _adapter().suggest_matches( + statement_line_ids=ids, + limit_per_line=int(limit_per_line), + ) + return {'suggestions': result} + + # ------------------------------------------------------------------ + # 5. accept_suggestion -- promote AI suggestion to real reconcile + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/accept_suggestion', type='jsonrpc', auth='user') + def accept_suggestion(self, suggestion_id): + """Accept a fusion suggestion. Returns the partial IDs created.""" + sug = request.env['fusion.reconcile.suggestion'].browse( + int(suggestion_id)) + if not sug.exists(): + raise ValidationError( + _("Suggestion %s not found") % suggestion_id) + # Capture the journal/company before reconcile (the sug may go stale). + journal_id = sug.statement_line_id.journal_id.id + company_id = sug.company_id.id + result = _adapter().accept_suggestion(suggestion_id=int(suggestion_id)) + unreconciled_count_after = request.env[ + 'account.bank.statement.line'].search_count([ + ('journal_id', '=', journal_id), + ('is_reconciled', '=', False), + ('company_id', '=', company_id), + ]) + return { + 'status': 'accepted', + 'partial_ids': result.get('partial_ids', []), + 'unreconciled_count_after': unreconciled_count_after, + } + + # ------------------------------------------------------------------ + # 6. reconcile_manual -- user picked candidates manually + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/reconcile_manual', type='jsonrpc', auth='user') + def reconcile_manual(self, statement_line_id, against_move_line_ids): + """Reconcile a line against an explicit set of journal items.""" + line = request.env['account.bank.statement.line'].browse( + int(statement_line_id)) + if not line.exists(): + raise ValidationError( + _("Statement line %s not found") % statement_line_id) + cands = request.env['account.move.line'].browse( + [int(i) for i in (against_move_line_ids or [])]) + result = request.env['fusion.reconcile.engine'].reconcile_one( + line, against_lines=cands) + return { + 'status': 'reconciled', + 'partial_ids': result.get('partial_ids', []), + } + + # ------------------------------------------------------------------ + # 7. unreconcile -- reverse a prior reconcile + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/unreconcile', type='jsonrpc', auth='user') + def unreconcile(self, partial_reconcile_ids): + """Reverse one or more partial reconciles.""" + ids = [int(i) for i in (partial_reconcile_ids or [])] + result = _adapter().unreconcile(partial_reconcile_ids=ids) + return { + 'status': 'unreconciled', + 'unreconciled_line_ids': result.get('unreconciled_line_ids', []), + } + + # ------------------------------------------------------------------ + # 8. write_off -- absorb residual into a write-off account + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/write_off', type='jsonrpc', auth='user') + def write_off(self, statement_line_id, account_id, amount, label, + tax_id=None): + """Apply a write-off against a bank statement line.""" + line = request.env['account.bank.statement.line'].browse( + int(statement_line_id)) + if not line.exists(): + raise ValidationError( + _("Statement line %s not found") % statement_line_id) + account = request.env['account.account'].browse(int(account_id)) + tax = (request.env['account.tax'].browse(int(tax_id)) + if tax_id else None) + result = request.env['fusion.reconcile.engine'].write_off( + line, account=account, amount=float(amount), + tax_id=tax, label=label) + return { + 'status': 'written_off', + 'partial_ids': result.get('partial_ids', []), + 'write_off_move_id': result.get('write_off_move_id'), + } + + # ------------------------------------------------------------------ + # 9. bulk_reconcile -- batch auto-reconcile a recordset + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/bulk_reconcile', type='jsonrpc', auth='user') + def bulk_reconcile(self, statement_line_ids, strategy='auto'): + """Batch auto-reconcile. Returns counts + per-line errors.""" + ids = [int(i) for i in (statement_line_ids or [])] + lines = request.env['account.bank.statement.line'].browse(ids) + result = request.env['fusion.reconcile.engine'].reconcile_batch( + lines, strategy=strategy) + return result + + # ------------------------------------------------------------------ + # 10. get_partner_history -- partner reconcile history panel + # ------------------------------------------------------------------ + + @http.route('/fusion/bank_rec/get_partner_history', type='jsonrpc', auth='user') + def get_partner_history(self, partner_id, limit=20): + """Return a partner's reconcile history + learned pattern.""" + Partner = request.env['res.partner'] + partner = Partner.browse(int(partner_id)) + if not partner.exists(): + raise ValidationError(_("Partner %s not found") % partner_id) + Precedent = request.env['fusion.reconcile.precedent'] + recent = Precedent.search( + [('partner_id', '=', partner.id)], + order='reconciled_at desc, id desc', + limit=int(limit), + ) + Pattern = request.env['fusion.reconcile.pattern'] + pattern = Pattern.search( + [('partner_id', '=', partner.id)], limit=1) + return { + 'partner': { + 'id': partner.id, + 'name': partner.name, + }, + 'recent_reconciles': [{ + 'precedent_id': p.id, + 'date': str(p.date) if p.date else None, + 'amount': p.amount, + 'memo_tokens': p.memo_tokens or '', + 'matched_count': p.matched_move_line_count, + 'source': p.source, + } for p in recent], + 'pattern': ({ + 'reconcile_count': pattern.reconcile_count, + 'pref_strategy': pattern.pref_strategy or None, + 'common_memo_tokens': pattern.common_memo_tokens or None, + 'typical_cadence_days': pattern.typical_cadence_days, + } if pattern else None), + } diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 40cd7245..2bb89b97 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -15,3 +15,4 @@ from . import test_bank_rec_tools from . import test_legacy_tools_refactor from . import test_mv_unreconciled from . import test_cron_methods +from . import test_controller diff --git a/fusion_accounting_bank_rec/tests/test_controller.py b/fusion_accounting_bank_rec/tests/test_controller.py new file mode 100644 index 00000000..f3d78cd4 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_controller.py @@ -0,0 +1,333 @@ +"""Tests for the fusion bank-rec HTTP controller (Task 26). + +Uses ``HttpCase`` so we exercise the full Werkzeug stack -- the JSON-RPC +dispatcher, auth check, and route resolution all run for real. Tests +authenticate as a Fusion Accounting administrator (the realistic role +for a user driving the bank-rec widget); a separate test confirms the +``auth='user'`` decorator rejects anonymous traffic. +""" + +import json + +from odoo.tests.common import HttpCase, new_test_user, tagged + +from . import _factories as f + + +@tagged('post_install', '-at_install') +class TestBankRecController(HttpCase): + """End-to-end coverage of the 10 JSON-RPC endpoints.""" + + USER_LOGIN = 'ctrl_test_user' + USER_PASSWORD = 'ctrl_test_user' + + def setUp(self): + super().setUp() + # group_account_user grants accounting write perms AND auto-implies + # fusion_accounting_user via the security XML's implied_ids hook; + # group_fusion_accounting_admin grants full CRUD on the fusion + # suggestion / precedent / pattern models the engine writes to. + self.user = new_test_user( + self.env, + login=self.USER_LOGIN, + password=self.USER_PASSWORD, + groups=( + 'base.group_user,' + 'account.group_account_user,' + 'fusion_accounting_core.group_fusion_accounting_admin' + ), + ) + self.partner = self.env['res.partner'].create( + {'name': 'Controller Test Partner'}) + self.journal = f.make_bank_journal( + self.env, name='Ctrl Bank', code='CBNK') + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + def _jsonrpc(self, endpoint, params, *, authenticate=True): + """POST a JSON-RPC envelope to ``/fusion/bank_rec/``. + + Returns the ``result`` dict on success and fails the test if + the body has an ``error`` key (so endpoint test failures show + the actual server-side exception, not just the HTTP status). + """ + if authenticate: + self.authenticate(self.USER_LOGIN, self.USER_PASSWORD) + url = f'/fusion/bank_rec/{endpoint}' + body = { + 'jsonrpc': '2.0', + 'method': 'call', + 'params': params, + 'id': 1, + } + response = self.url_open( + url, + data=json.dumps(body), + headers={'Content-Type': 'application/json'}, + ) + self.assertEqual( + response.status_code, 200, + f"Endpoint {endpoint} returned {response.status_code}: " + f"{response.text[:300]}") + payload = response.json() + if 'error' in payload: + self.fail( + f"Endpoint {endpoint} errored: " + f"{json.dumps(payload['error'])[:600]}") + return payload.get('result', {}) + + # ------------------------------------------------------------------ + # 1. get_state + # ------------------------------------------------------------------ + + def test_get_state(self): + result = self._jsonrpc('get_state', { + 'journal_id': self.journal.id, + 'company_id': self.env.company.id, + }) + self.assertIn('journal', result) + self.assertEqual(result['journal']['id'], self.journal.id) + self.assertIn('unreconciled_count', result) + self.assertIn('total_pending_amount', result) + self.assertIn('last_statement_date', result) + + # ------------------------------------------------------------------ + # 2. list_unreconciled + # ------------------------------------------------------------------ + + def test_list_unreconciled(self): + # Reuse a single statement so we don't trip the + # (journal_id, name) uniqueness or hit the parent-move autocreate + # path twice in the same flush window. + statement = f.make_bank_statement( + self.env, journal=self.journal, name='List Stmt') + f.make_bank_line( + self.env, journal=self.journal, statement=statement, + amount=100, partner=self.partner, memo='List 1') + f.make_bank_line( + self.env, journal=self.journal, statement=statement, + amount=200, partner=self.partner, memo='List 2') + result = self._jsonrpc('list_unreconciled', { + 'journal_id': self.journal.id, + 'limit': 50, + 'offset': 0, + 'company_id': self.env.company.id, + }) + self.assertIn('lines', result) + self.assertGreaterEqual(len(result['lines']), 2) + self.assertGreaterEqual(result['total'], 2) + first = result['lines'][0] + for key in ('id', 'amount', 'fusion_top_suggestion_id', + 'fusion_confidence_band', 'attachment_count'): + self.assertIn(key, first) + + # ------------------------------------------------------------------ + # 3. get_line_detail + # ------------------------------------------------------------------ + + def test_get_line_detail(self): + line = f.make_bank_line( + self.env, journal=self.journal, amount=100, partner=self.partner) + f.make_suggestion( + self.env, statement_line=line, confidence=0.85) + result = self._jsonrpc( + 'get_line_detail', {'statement_line_id': line.id}) + self.assertEqual(result['line']['id'], line.id) + self.assertEqual(result['line']['amount'], 100.0) + self.assertGreaterEqual(len(result['suggestions']), 1) + sug = result['suggestions'][0] + for key in ('id', 'candidate_ids', 'confidence', 'rank', + 'reasoning', 'scores'): + self.assertIn(key, sug) + + # ------------------------------------------------------------------ + # 4. suggest_matches + # ------------------------------------------------------------------ + + def test_suggest_matches(self): + f.make_invoice(self.env, partner=self.partner, amount=300) + line = f.make_bank_line( + self.env, journal=self.journal, amount=300, partner=self.partner) + result = self._jsonrpc('suggest_matches', { + 'statement_line_ids': [line.id], + 'limit_per_line': 3, + }) + self.assertIn('suggestions', result) + self.assertIsInstance(result['suggestions'], dict) + + # ------------------------------------------------------------------ + # 5. accept_suggestion + # ------------------------------------------------------------------ + + def test_accept_suggestion(self): + invoice = f.make_invoice( + self.env, partner=self.partner, amount=400) + recv = invoice.line_ids.filtered( + lambda l: l.account_id.account_type == 'asset_receivable') + line = f.make_bank_line( + self.env, journal=self.journal, amount=400, partner=self.partner) + sug = f.make_suggestion( + self.env, statement_line=line, + candidate_move_lines=recv, confidence=0.92) + result = self._jsonrpc( + 'accept_suggestion', {'suggestion_id': sug.id}) + self.assertEqual(result['status'], 'accepted') + self.assertGreater(len(result['partial_ids']), 0) + self.assertIn('unreconciled_count_after', result) + + # ------------------------------------------------------------------ + # 6. reconcile_manual + # ------------------------------------------------------------------ + + def _make_pair(self, *, amount, statement=None): + """Inline reconcile-able pair against ``self.journal``. + + The shared ``make_reconcileable_pair`` factory creates a fresh bank + journal per call (default code 'TEST'), which collides with the + unique (code, company) constraint when used multiple times in one + test. Reusing ``self.journal`` (and optionally a shared statement) + keeps every pair on the same journal. + """ + invoice = f.make_invoice( + self.env, partner=self.partner, amount=amount) + recv = invoice.line_ids.filtered( + lambda l: l.account_id.account_type == 'asset_receivable') + line = f.make_bank_line( + self.env, journal=self.journal, statement=statement, + amount=amount, partner=self.partner) + return line, recv + + def test_reconcile_manual(self): + line, recv = self._make_pair(amount=550) + result = self._jsonrpc('reconcile_manual', { + 'statement_line_id': line.id, + 'against_move_line_ids': recv.ids, + }) + self.assertEqual(result['status'], 'reconciled') + self.assertGreater(len(result['partial_ids']), 0) + + # ------------------------------------------------------------------ + # 7. unreconcile + # ------------------------------------------------------------------ + + def test_unreconcile(self): + line, recv = self._make_pair(amount=625) + rec = self.env['fusion.reconcile.engine'].reconcile_one( + line, against_lines=recv) + result = self._jsonrpc('unreconcile', { + 'partial_reconcile_ids': rec['partial_ids'], + }) + self.assertEqual(result['status'], 'unreconciled') + self.assertGreater(len(result['unreconciled_line_ids']), 0) + + # ------------------------------------------------------------------ + # 8. write_off -- smoke only (Task 12 deferred full coverage) + # ------------------------------------------------------------------ + + def test_write_off_smoke(self): + line = f.make_bank_line( + self.env, journal=self.journal, amount=12.34, + partner=self.partner) + # Pick any expense-type account that exists in the chart. + wo_account = self.env['account.account'].search([ + ('account_type', '=', 'expense'), + ('company_ids', 'in', self.env.company.id), + ], limit=1) + if not wo_account: + self.skipTest("No expense account available for write-off smoke") + # Endpoint must respond without 500-erroring; engine may legitimately + # raise a ValidationError for an over-allocation, in which case the + # JSON-RPC response will include an 'error' key. We accept either + # success or a structured error -- what we are guarding against is a + # routing-layer regression (NameError, missing import, etc.). + url = '/fusion/bank_rec/write_off' + self.authenticate(self.USER_LOGIN, self.USER_PASSWORD) + body = { + 'jsonrpc': '2.0', 'method': 'call', 'id': 1, + 'params': { + 'statement_line_id': line.id, + 'account_id': wo_account.id, + 'amount': line.amount, + 'label': 'Smoke write-off', + }, + } + response = self.url_open( + url, data=json.dumps(body), + headers={'Content-Type': 'application/json'}) + self.assertEqual( + response.status_code, 200, + f"write_off returned {response.status_code}: " + f"{response.text[:300]}") + + # ------------------------------------------------------------------ + # 9. bulk_reconcile + # ------------------------------------------------------------------ + + def test_bulk_reconcile(self): + statement = f.make_bank_statement( + self.env, journal=self.journal, name='Bulk Stmt') + line_ids = [] + for amt in (110, 220, 330): + line, _recv = self._make_pair(amount=amt, statement=statement) + line_ids.append(line.id) + result = self._jsonrpc('bulk_reconcile', { + 'statement_line_ids': line_ids, + 'strategy': 'auto', + }) + self.assertIn('reconciled_count', result) + self.assertGreaterEqual(result['reconciled_count'], 3) + + # ------------------------------------------------------------------ + # 10. get_partner_history + # ------------------------------------------------------------------ + + def test_get_partner_history(self): + for d in (5, 12, 20): + f.make_precedent( + self.env, partner=self.partner, days_ago=d, amount=1000) + f.make_pattern( + self.env, partner=self.partner, reconcile_count=3) + result = self._jsonrpc('get_partner_history', { + 'partner_id': self.partner.id, + 'limit': 10, + }) + self.assertEqual(result['partner']['id'], self.partner.id) + self.assertGreaterEqual(len(result['recent_reconciles']), 3) + self.assertIsNotNone(result['pattern']) + self.assertEqual(result['pattern']['reconcile_count'], 3) + + # ------------------------------------------------------------------ + # 11. unauthenticated traffic is blocked + # ------------------------------------------------------------------ + + def test_unauthenticated_request_blocked(self): + # Use a fresh session by creating a new opener -- self.url_open + # reuses the test session, which `authenticate()` would mutate. + url = '/fusion/bank_rec/get_state' + body = { + 'jsonrpc': '2.0', 'method': 'call', 'id': 1, + 'params': { + 'journal_id': self.journal.id, + 'company_id': self.env.company.id, + }, + } + # No call to self.authenticate() -> session has no uid. + response = self.url_open( + url, data=json.dumps(body), + headers={'Content-Type': 'application/json'}, + allow_redirects=False, + ) + # Odoo's auth='user' on a JSON-RPC route returns a 200 with an + # error envelope (SessionExpiredException) when not authenticated; + # what must NOT happen is the handler running and returning our + # success payload. + if response.status_code == 200: + payload = response.json() + self.assertIn( + 'error', payload, + "Unauthenticated request should not return a success result") + else: + # 3xx redirect or 4xx are also acceptable rejections. + self.assertGreaterEqual(response.status_code, 300)