Skip to content

Commit 6cfdeed

Browse files
committed
Extend tests for interactive mode
1 parent b22c92c commit 6cfdeed

File tree

6 files changed

+162
-84
lines changed

6 files changed

+162
-84
lines changed

odooly.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2415,11 +2415,8 @@ def excepthook(exc_type, exc, tb):
24152415
print(msg.strip())
24162416
sys.excepthook = excepthook
24172417

2418-
class Usage:
2419-
def __call__(self):
2420-
print(usage)
2421-
__repr__ = lambda s: usage
2422-
builtins.usage = Usage()
2418+
builtins.usage = type('Usage', (), {'__call__': lambda s: print(usage),
2419+
'__repr__': lambda s: usage})()
24232420

24242421
try:
24252422
import readline as rl

tests/_common.py

Lines changed: 65 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from io import BytesIO
2-
from urllib.request import HTTPError
2+
from urllib.request import HTTPError, urljoin
33
from unittest import mock, TestCase
4-
from unittest.mock import call, sentinel
4+
from unittest.mock import ANY, call, sentinel
55

66
import odooly
77

8-
sample_context = {'lang': 'en_US', 'tz': 'Europe/Zurich'}
98
type_call = type(call)
109

1110

@@ -20,65 +19,42 @@ def popvalue(self):
2019

2120
def OBJ(model, method, *params, **kw):
2221
if 'context' not in kw:
23-
kw['context'] = sample_context
22+
kw['context'] = {**OdooTestCase.user_context}
2423
elif kw['context'] is None:
2524
del kw['context']
2625
return ('object.execute_kw', sentinel.AUTH, model, method, params) + ((kw,) if kw else ())
2726

2827

29-
class XmlRpcTestCase(TestCase):
28+
class OdooTestCase(TestCase):
3029
server_version = None
3130
server = "http://192.0.2.199:9999"
3231
database = user = password = uid = None
32+
user_context = {'lang': 'en_US', 'tz': 'Europe/Zurich'}
3333
maxDiff = None
3434

3535
def setUp(self):
3636
self.addCleanup(mock.patch.stopall)
3737
self.stdout = mock.patch('sys.stdout', new=PseudoFile()).start()
3838
self.stderr = mock.patch('sys.stderr', new=PseudoFile()).start()
39+
self.http_request = self._patch_http_request()
3940

4041
# Clear the login cache
4142
mock.patch.dict('odooly.Env._cache', clear=True).start()
4243

4344
# Avoid hanging on getpass
4445
mock.patch('odooly.getpass', side_effect=RuntimeError).start()
4546

46-
self.service = self._patch_service()
47-
if self.server and self.database:
48-
# create the client
49-
self.client = odooly.Client(
50-
self.server, self.database, self.user, self.password)
51-
self.env = self.client.env
52-
# reset the mock
53-
self.service.reset_mock()
54-
55-
def _patch_http_request(self, uid=None, context=sample_context):
47+
def _patch_http_request(self, uid=None, context=None):
5648
def func(url, *, method='POST', data=None, json=None, headers=None):
5749
if url.endswith("/web/session/authenticate"):
58-
result = {'uid': uid or self.uid, 'user_context': context}
50+
result = {'uid': uid or self.uid, 'user_context': context or self.user_context}
5951
else:
6052
with HTTPError(url, 404, 'Not Found', headers, BytesIO()) as not_found:
6153
raise not_found
6254
return {'result': result}
6355
return mock.patch('odooly.HTTPSession.request', side_effect=func).start()
6456

65-
def _patch_service(self):
66-
def get_svc(server, name, *args, **kwargs):
67-
return getattr(svcs, name)
68-
patcher = mock.patch('odooly.Service', side_effect=get_svc)
69-
svcs = patcher.start()
70-
svcs.stop = patcher.stop
71-
for svc_name in 'db common object wizard report'.split():
72-
svcs.attach_mock(mock.Mock(name=svc_name), svc_name)
73-
# Default values
74-
svcs.db.server_version.return_value = self.server_version
75-
svcs.db.list.return_value = [self.database]
76-
svcs.common.login.return_value = self.uid
77-
# env['res.users'].context_get()
78-
svcs.object.execute_kw.return_value = sample_context
79-
return svcs
80-
81-
def _assertCalls(self, mock_, expected_calls):
57+
def assertMockCalls(self, mock_, expected_calls):
8258
for idx, expected in enumerate(expected_calls):
8359
if isinstance(expected, str):
8460
if expected[:4] == 'call':
@@ -88,17 +64,22 @@ def _assertCalls(self, mock_, expected_calls):
8864
self.assertSequenceEqual(mock_.mock_calls, expected_calls)
8965
mock_.reset_mock()
9066

91-
def assertServiceCalls(self, *expected_args):
67+
def assertRequests(self, *expected_args):
68+
server = urljoin(self.server, '/').rstrip('/')
9269
expected_calls = list(expected_args)
9370
for idx, expected in enumerate(expected_calls):
94-
if not isinstance(expected, type_call) and isinstance(expected, tuple):
95-
rpcmethod = expected[0]
96-
if len(expected) > 1 and expected[1] == sentinel.AUTH:
97-
args = (self.database, self.uid, self.password) + expected[2:]
98-
else:
99-
args = expected[1:]
100-
expected_calls[idx] = getattr(call, rpcmethod)(*args)
101-
self._assertCalls(self.service, expected_calls)
71+
if isinstance(expected, tuple):
72+
if expected[0].startswith('/json/2/'):
73+
headers = {
74+
'Authorization': f'Bearer {self.password}',
75+
'Content-Type': 'application/json',
76+
'X-Odoo-Database': self.database,
77+
}
78+
expected_calls[idx] = call(f"{server}{expected[0]}", json=expected[1], headers=headers)
79+
elif expected[0].startswith('/web/'):
80+
jsonrpc_params = {'jsonrpc': '2.0', 'method': 'call', 'params': expected[1], 'id': ANY}
81+
expected_calls[idx] = call(f"{server}{expected[0]}", json=jsonrpc_params)
82+
self.assertMockCalls(self.http_request, expected_calls)
10283

10384
def assertOutput(self, stdout='', stderr='', startswith=False):
10485
# compare with ANY to make sure output is not empty
@@ -117,5 +98,47 @@ def assertOutput(self, stdout='', stderr='', startswith=False):
11798
stdout_value = stdout_value[:len(stdout)]
11899
self.assertMultiLineEqual(stdout_value, stdout)
119100

101+
102+
class XmlRpcTestCase(OdooTestCase):
103+
104+
def setUp(self):
105+
super().setUp()
106+
self.service = self._patch_service()
107+
if self.server and self.database:
108+
# create the client
109+
self.client = odooly.Client(
110+
self.server, self.database, self.user, self.password)
111+
self.env = self.client.env
112+
# reset the mock
113+
self.service.reset_mock()
114+
115+
def _patch_service(self):
116+
def get_svc(server, name, *args, **kwargs):
117+
return getattr(svcs, name)
118+
patcher = mock.patch('odooly.Service', side_effect=get_svc)
119+
svcs = patcher.start()
120+
svcs.stop = patcher.stop
121+
for svc_name in 'db common object wizard report'.split():
122+
svcs.attach_mock(mock.Mock(name=svc_name), svc_name)
123+
# Default values
124+
svcs.db.server_version.return_value = self.server_version
125+
svcs.db.list.return_value = [self.database]
126+
svcs.common.login.return_value = self.uid
127+
# env['res.users'].context_get()
128+
svcs.object.execute_kw.return_value = self.user_context
129+
return svcs
130+
131+
def assertServiceCalls(self, *expected_args):
132+
expected_calls = list(expected_args)
133+
for idx, expected in enumerate(expected_calls):
134+
if not isinstance(expected, type_call) and isinstance(expected, tuple):
135+
rpcmethod = expected[0]
136+
if len(expected) > 1 and expected[1] == sentinel.AUTH:
137+
args = (self.database, self.uid, self.password) + expected[2:]
138+
else:
139+
args = expected[1:]
140+
expected_calls[idx] = getattr(call, rpcmethod)(*args)
141+
self.assertMockCalls(self.service, expected_calls)
142+
120143
# Legacy
121144
assertCalls = assertServiceCalls

tests/test_client.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -757,10 +757,6 @@ class TestClientApi19(TestClientApi):
757757
test_exec_workflow = test_wizard = _skip_test
758758
test_report = test_render_report = test_report_get = _skip_test
759759

760-
def _patch_service(self):
761-
self.auth_http = self._patch_http_request()
762-
return super()._patch_service()
763-
764760
def test_obsolete_methods(self):
765761
self.assertRaises(AttributeError, getattr, self.env, 'exec_workflow')
766762
self.assertRaises(AttributeError, getattr, self.env, 'render_report')

tests/test_interact.py

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,35 @@
44
from unittest.mock import call, ANY
55

66
import odooly
7-
from ._common import XmlRpcTestCase
7+
from ._common import OdooTestCase, XmlRpcTestCase
88

99

10-
class TestInteract(XmlRpcTestCase):
10+
class _TestInteract(OdooTestCase):
11+
12+
def setUp(self):
13+
super().setUp()
14+
# Hide readline module
15+
mock.patch.dict('sys.modules', {'readline': None}).start()
16+
mock.patch('odooly.Client._globals', None).start()
17+
mock.patch('odooly.Client._set_interactive', wraps=odooly.Client._set_interactive).start()
18+
self.interact = mock.patch('odooly._interact', wraps=odooly._interact).start()
19+
self.infunc = mock.patch('code.InteractiveConsole.raw_input').start()
20+
mock.patch('odooly.main.__defaults__', (self.interact,)).start()
21+
22+
def _resp_version_info(self):
23+
[major, minor] = self.server_version.split('.')
24+
return {
25+
'server_version': self.server_version,
26+
'server_version_info': [int(major), int(minor), 0, 'final', 0, ''],
27+
'server_serie': self.server_version,
28+
'protocol_version': 1,
29+
}
30+
31+
32+
class TestInteractXmlRpc(XmlRpcTestCase, _TestInteract):
33+
"""Test interactive mode with OpenERP 6.1."""
1134
server_version = '6.1'
12-
server = f"{XmlRpcTestCase.server}/xmlrpc"
35+
server = f"{OdooTestCase.server}/xmlrpc"
1336
startup_calls = (
1437
call(ANY, 'db', ANY),
1538
'db.server_version',
@@ -21,16 +44,6 @@ class TestInteract(XmlRpcTestCase):
2144
'db.list',
2245
)
2346

24-
def setUp(self):
25-
super().setUp()
26-
# Hide readline module
27-
mock.patch.dict('sys.modules', {'readline': None}).start()
28-
mock.patch('odooly.Client._globals', None).start()
29-
mock.patch('odooly.Client._set_interactive', wraps=odooly.Client._set_interactive).start()
30-
self.interact = mock.patch('odooly._interact', wraps=odooly._interact).start()
31-
self.infunc = mock.patch('code.InteractiveConsole.raw_input').start()
32-
mock.patch('odooly.main.__defaults__', (self.interact,)).start()
33-
3447
def test_main(self):
3548
env_tuple = (self.server, 'database', 'usr', None, None)
3649
mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start()
@@ -147,3 +160,70 @@ def usr17(model, method, *params):
147160
'Model not found: res.company',
148161
])
149162
self.assertOutput(stderr=ANY)
163+
164+
165+
class TestInteract19(_TestInteract):
166+
"""Test interactive mode with Odoo 19."""
167+
server_version = '19.0'
168+
server = f"{OdooTestCase.server}/"
169+
database, user, password, uid = 'database', 'usr', 'password', 17
170+
startup_calls = (
171+
('/web/webclient/version_info', {}),
172+
('/web/database/list', {}),
173+
('/web/session/authenticate', {'db': database, 'login': user, 'password': password}),
174+
('/json/2/res.users/context_get', {}),
175+
)
176+
177+
def test_main(self):
178+
env_tuple = (self.server, self.database, self.user, None, None)
179+
mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start()
180+
read_config = mock.patch('odooly.Client.get_config',
181+
return_value=env_tuple).start()
182+
getpass = mock.patch('odooly.getpass',
183+
return_value=self.password).start()
184+
self.http_request.side_effect = [
185+
{'result': self._resp_version_info()},
186+
[],
187+
{'result': {'uid': 17, 'user_context': self.user_context}},
188+
{'uid': self.uid, **self.user_context},
189+
#
190+
{'result': {'uid': 51, 'user_context': self.user_context}},
191+
OSError,
192+
{'result': {'uid': 51, 'user_context': self.user_context}},
193+
]
194+
195+
# Launch interactive
196+
self.infunc.side_effect = [
197+
"client\n",
198+
"env\n",
199+
"env.sudo('gaspard')\n",
200+
"client.login('gaspard')\n",
201+
"23 + 19\n",
202+
EOFError('Finished')]
203+
odooly.main()
204+
205+
self.assertEqual(sys.ps1, 'demo >>> ')
206+
self.assertEqual(sys.ps2, ' ... ')
207+
expected_calls = self.startup_calls + (
208+
('/web/session/authenticate', {'db': 'database', 'login': 'gaspard', 'password': 'password'}),
209+
('/json/2/res.users/context_get', {}),
210+
('/web/session/authenticate', {'db': 'database', 'login': 'gaspard', 'password': 'password'}),
211+
)
212+
self.assertRequests(*expected_calls)
213+
self.assertEqual(getpass.call_count, 2)
214+
self.assertEqual(read_config.call_count, 1)
215+
self.assertEqual(self.interact.call_count, 1)
216+
outlines = self.stdout.popvalue().splitlines()
217+
self.assertSequenceEqual(outlines[-6:], [
218+
"Logged in as 'usr'",
219+
f"<Client '{self.server}web?db=database'>",
220+
"<Env 'usr@database'>",
221+
"<Env 'gaspard@database'>",
222+
"Logged in as 'gaspard'",
223+
"42",
224+
])
225+
self.assertOutput(stderr='\x1b[A\n\n', startswith=True)
226+
227+
# TODO
228+
test_no_database = None
229+
test_invalid_user_password = None

tests/test_model.py

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from functools import partial
2-
from unittest.mock import call, sentinel, ANY
3-
from urllib.request import urljoin
2+
from unittest.mock import sentinel, ANY
43

54
import odooly
65
from ._common import XmlRpcTestCase, OBJ
@@ -120,6 +119,7 @@ def setUp(self):
120119
self.service.object.execute_kw.side_effect = self.obj_exec
121120
# preload 'foo.bar'
122121
self.env['foo.bar']
122+
self.http_request.reset_mock()
123123
self.service.reset_mock()
124124

125125

@@ -1605,23 +1605,6 @@ class TestRecord18(TestRecord):
16051605
class TestModel19(TestModel):
16061606
server_version = '19.0'
16071607

1608-
def _patch_service(self):
1609-
self.auth_http = self._patch_http_request()
1610-
return super()._patch_service()
1611-
1612-
def test_auth_http(self):
1613-
headers = {
1614-
'Authorization': 'Bearer passwd',
1615-
'Content-Type': 'application/json',
1616-
'X-Odoo-Database': 'database',
1617-
}
1618-
test_url = urljoin(self.server, '/json/2/res.users/context_get')
1619-
self.assertEqual(self.auth_http.mock_calls, [call(test_url, json={}, headers=headers)])
1620-
16211608

16221609
class TestRecord19(TestRecord):
16231610
server_version = '19.0'
1624-
1625-
def _patch_service(self):
1626-
self.auth_http = self._patch_http_request()
1627-
return super()._patch_service()

tests/test_util.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ def test_searchargs_invalid(self):
130130
self.assertRaises(ValueError, searchargs, (['some_id child_off'],))
131131
self.assertRaises(ValueError, searchargs, (['someth like3'],))
132132

133-
134133
def test_readfmt(self):
135134
dummy = object.__new__(Model)
136135
readfmt = partial(dummy._parse_format, browse=False)

0 commit comments

Comments
 (0)