Merge lp:~frankban/charms/precise/juju-gui/guiserver-views into lp:~juju-gui/charms/precise/juju-gui/trunk

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 93
Proposed branch: lp:~frankban/charms/precise/juju-gui/guiserver-views
Merge into: lp:~juju-gui/charms/precise/juju-gui/trunk
Diff against target: 671 lines (+616/-2)
7 files modified
revision (+1/-1)
server/guiserver/bundles/__init__.py (+2/-1)
server/guiserver/bundles/utils.py (+54/-0)
server/guiserver/bundles/views.py (+177/-0)
server/guiserver/tests/bundles/test_utils.py (+108/-0)
server/guiserver/tests/bundles/test_views.py (+259/-0)
server/guiserver/tests/helpers.py (+15/-0)
To merge this branch: bzr merge lp:~frankban/charms/precise/juju-gui/guiserver-views
Reviewer Review Type Date Requested Status
charmers Pending
Review via email: mp+181020@code.launchpad.net

Description of the change

Bundle deployment views.

This branch includes the implementation of the
bundle views. Their goal is to handle the bundle
deployment request/response process.

Views interact with the Deployer instance
(not yet implemented) in order to schedule,
run and observe bundle deployments.

https://codereview.appspot.com/12927049/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+181020_code.launchpad.net,

Message:
Please take a look.

Description:
Bundle deployment views.

This branch includes the implementation of the
bundle views. Their goal is to handle the bundle
deployment request/response process.

Views interact with the Deployer instance
(not yet implemented) in order to schedule,
run and observe bundle deployments.

https://code.launchpad.net/~frankban/charms/precise/juju-gui/guiserver-views/+merge/181020

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/12927049/

Affected files:
   A [revision details]
   M revision
   M server/guiserver/bundles/__init__.py
   A server/guiserver/bundles/utils.py
   A server/guiserver/bundles/views.py
   A server/guiserver/tests/bundles/test_utils.py
   A server/guiserver/tests/bundles/test_views.py
   M server/guiserver/tests/helpers.py

Revision history for this message
Brad Crittenden (bac) wrote :

LGTM. Nice tests and well-organized helpers / factoring.

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/utils.py
File server/guiserver/bundles/utils.py (right):

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/utils.py#newcode36
server/guiserver/bundles/utils.py:36: raise response(error='unauthorized
access: unknown user')
The error message is a little misleading. "unknown user" implies we
have a user but don't recognize her when in fact there is no logged in
user.

How about:

error='unauthorized access: no user logged in.' ?

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py
File server/guiserver/bundles/views.py (right):

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py#newcode37
server/guiserver/bundles/views.py:37: creating this kind of responses:
s/creating this kind of responses/create these responses

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py#newcode173
server/guiserver/bundles/views.py:173: raise response(error='invalid
request: invalid data parameters')
Perhaps list the params in the error message?

https://codereview.appspot.com/12927049/

109. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

Bundle deployment views.

This branch includes the implementation of the
bundle views. Their goal is to handle the bundle
deployment request/response process.

Views interact with the Deployer instance
(not yet implemented) in order to schedule,
run and observe bundle deployments.

R=bac, benji
CC=
https://codereview.appspot.com/12927049

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/utils.py
File server/guiserver/bundles/utils.py (right):

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/utils.py#newcode36
server/guiserver/bundles/utils.py:36: raise response(error='unauthorized
access: unknown user')
On 2013/08/20 13:24:09, bac wrote:
> The error message is a little misleading. "unknown user" implies we
have a user
> but don't recognize her when in fact there is no logged in user.

> How about:

> error='unauthorized access: no user logged in.' ?

You are right, changed the message.

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py
File server/guiserver/bundles/views.py (right):

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py#newcode37
server/guiserver/bundles/views.py:37: creating this kind of responses:
On 2013/08/20 13:24:09, bac wrote:
> s/creating this kind of responses/create these responses

Done.

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py#newcode60
server/guiserver/bundles/views.py:60: (the latter will be eventually
fixed switching to a newer version of Python).
On 2013/08/20 13:28:10, benji wrote:
> I was wondering about why we didn't use Python 3 for this server. Is
there a
> Tornado requirement for Python 2?

No, Tornado is fully compatible with Python3. And I guess it should be
quite easy to port the GUI server to Python3. The problem here is that
juju-deployer only supports Python2, and therefore, for now, we cannot
upgrade.

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py#newcode148
server/guiserver/bundles/views.py:148: deployment being observed. If
unseen changes are available, a response is
On 2013/08/20 13:28:10, benji wrote:
> "unsent" might be slightly clearer than "unseen".

Done.

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py#newcode149
server/guiserver/bundles/views.py:149: suddenly returned containing the
changes. Otherwise, this views suspends
On 2013/08/20 13:28:10, benji wrote:
> I think "immediately" would be a bit better than "suddenly".

Done.

https://codereview.appspot.com/12927049/diff/1/server/guiserver/bundles/views.py#newcode173
server/guiserver/bundles/views.py:173: raise response(error='invalid
request: invalid data parameters')
On 2013/08/20 13:24:09, bac wrote:
> Perhaps list the params in the error message?

Done.

https://codereview.appspot.com/12927049/

Revision history for this message
Francesco Banconi (frankban) wrote :

Thank you both for the reviews.

https://codereview.appspot.com/12927049/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'revision'
2--- revision 2013-08-19 08:50:17 +0000
3+++ revision 2013-08-20 14:10:33 +0000
4@@ -1,1 +1,1 @@
5-71
6+72
7
8=== modified file 'server/guiserver/bundles/__init__.py'
9--- server/guiserver/bundles/__init__.py 2013-08-19 08:41:44 +0000
10+++ server/guiserver/bundles/__init__.py 2013-08-20 14:10:33 +0000
11@@ -27,7 +27,8 @@
12 - validate(user, name, bundle) -> Future (str or None);
13 - import_bundle(user, name, bundle) -> int (a deployment id);
14 - watch(deployment_id) -> int or None (a watcher id);
15- - next(watcher_id) -> Future (changes or None).
16+ - next(watcher_id) -> Future (changes or None);
17+ - status() -> list (of changes).
18
19 The following arguments are passed to the validate and import_bundle
20 interface methods:
21
22=== added file 'server/guiserver/bundles/utils.py'
23--- server/guiserver/bundles/utils.py 1970-01-01 00:00:00 +0000
24+++ server/guiserver/bundles/utils.py 2013-08-20 14:10:33 +0000
25@@ -0,0 +1,54 @@
26+# This file is part of the Juju GUI, which lets users view and manage Juju
27+# environments within a graphical interface (https://launchpad.net/juju-gui).
28+# Copyright (C) 2013 Canonical Ltd.
29+#
30+# This program is free software: you can redistribute it and/or modify it under
31+# the terms of the GNU Affero General Public License version 3, as published by
32+# the Free Software Foundation.
33+#
34+# This program is distributed in the hope that it will be useful, but WITHOUT
35+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
36+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
37+# Affero General Public License for more details.
38+#
39+# You should have received a copy of the GNU Affero General Public License
40+# along with this program. If not, see <http://www.gnu.org/licenses/>.
41+
42+"""Bundle deployment utility functions."""
43+
44+from functools import wraps
45+import logging
46+
47+from tornado import gen
48+
49+
50+def require_authenticated_user(view):
51+ """Require the user to be authenticated when executing the decorated view.
52+
53+ This function can be used to decorate bundle views. Each view receives
54+ a request and a deployer, and the user instance is stored in request.user.
55+ If the user is not authenticated an error response is raised when calling
56+ the view. Otherwise, the view is executed normally.
57+ """
58+ @wraps(view)
59+ def decorated(request, deployer):
60+ if not request.user.is_authenticated:
61+ raise response(error='unauthorized access: no user logged in')
62+ return view(request, deployer)
63+ return decorated
64+
65+
66+def response(info=None, error=None):
67+ """Create a response containing the given (optional) info and error values.
68+
69+ This function is intended to be used by bundles views.
70+ Return a gen.Return instance, so that the result of this method can easily
71+ be raised from coroutines.
72+ """
73+ if info is None:
74+ info = {}
75+ data = {'Response': info}
76+ if error is not None:
77+ logging.error('deployer: {}'.format(error))
78+ data['Error'] = error
79+ return gen.Return(data)
80
81=== added file 'server/guiserver/bundles/views.py'
82--- server/guiserver/bundles/views.py 1970-01-01 00:00:00 +0000
83+++ server/guiserver/bundles/views.py 2013-08-20 14:10:33 +0000
84@@ -0,0 +1,177 @@
85+# This file is part of the Juju GUI, which lets users view and manage Juju
86+# environments within a graphical interface (https://launchpad.net/juju-gui).
87+# Copyright (C) 2013 Canonical Ltd.
88+#
89+# This program is free software: you can redistribute it and/or modify it under
90+# the terms of the GNU Affero General Public License version 3, as published by
91+# the Free Software Foundation.
92+#
93+# This program is distributed in the hope that it will be useful, but WITHOUT
94+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
95+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
96+# Affero General Public License for more details.
97+#
98+# You should have received a copy of the GNU Affero General Public License
99+# along with this program. If not, see <http://www.gnu.org/licenses/>.
100+
101+"""Bundle deployment views.
102+
103+This module includes the views used to create responses for bundle deployments
104+related requests. The bundles protocol, described in the bundles package
105+docstring, mimics the request/response paradigm over a WebSocket. Views are
106+simple functions that, given a request, return a response to be sent back to
107+the API client. Each view receives the following arguments:
108+
109+ - request: a request object with two attributes:
110+ - request.params: a dict representing the parameters sent by the client;
111+ - request.user: the current user (an instance of guiserver.auth.User);
112+ - deployer: a Deployer instance, ready to be used to schedule/start/observe
113+ bundle deployments.
114+
115+The response returned by views must be a Future containing the response data as
116+a dict-like object, e.g.:
117+
118+ {'Response': {}, 'Error': 'this field is optional'}
119+
120+The response function defined in the guiserver.bundles.utils module helps
121+create these responses:
122+
123+ from guiserver.bundles.utils import response
124+
125+ @gen.coroutine
126+ def succeeding_view(request, deployer)
127+ raise response('Success!')
128+
129+ @gen.coroutine
130+ def failing_view(request, deployer)
131+ raise response(error='Boo!')
132+
133+Use the require_authenticated_user decorator if the view requires a logged in
134+user, e.g.:
135+
136+ @gen.coroutine
137+ @require_authenticated_user
138+ def protected_view(request, deployer):
139+ # This function body is executed only if the user is authenticated.
140+
141+As seen in the examples above, views are also coroutines: they must be
142+decorated with tornado.gen.coroutine, they can suspend their own execution
143+using "yield", and they must return their results using "raise response(...)"
144+(the latter will be eventually fixed switching to a newer version of Python).
145+"""
146+
147+from tornado import gen
148+import yaml
149+
150+from guiserver.bundles.utils import (
151+ require_authenticated_user,
152+ response,
153+)
154+
155+
156+def _validate_import_params(params):
157+ """Parse the request data and return a (name, bundle) tuple.
158+
159+ In the tuple:
160+ - name is the name of the bundle to be imported;
161+ - bundle is the YAML decoded bundle object.
162+
163+ Raise a ValueError if data represents an invalid request.
164+ """
165+ name = params.get('Name')
166+ contents = params.get('YAML')
167+ if not (name and contents):
168+ raise ValueError('invalid data parameters')
169+ try:
170+ bundles = yaml.load(contents, Loader=yaml.SafeLoader)
171+ except Exception as err:
172+ raise ValueError('invalid YAML contents: {}'.format(err))
173+ bundle = bundles.get(name)
174+ if bundle is None:
175+ raise ValueError('bundle {} not found'.format(name))
176+ return name, bundle
177+
178+
179+@gen.coroutine
180+@require_authenticated_user
181+def import_bundle(request, deployer):
182+ """Start or schedule a bundle deployment.
183+
184+ If the request is valid, the response will contain the DeploymentId
185+ assigned to the bundle deployment.
186+
187+ Request: 'Import'.
188+ Parameters example: {'Name': 'bundle-name', 'YAML': 'bundles'}.
189+ """
190+ # Validate the request parameters.
191+ try:
192+ name, bundle = _validate_import_params(request.params)
193+ except ValueError as err:
194+ raise response(error='invalid request: {}'.format(err))
195+ # Validate the bundle against the current state of the Juju environment.
196+ err = yield deployer.validate(request.user, name, bundle)
197+ if err is not None:
198+ raise response(error='invalid request: {}'.format(err))
199+ # Add the bundle deployment to the Deployer queue.
200+ deployment_id = deployer.import_bundle(request.user, name, bundle)
201+ raise response({'DeploymentId': deployment_id})
202+
203+
204+@gen.coroutine
205+@require_authenticated_user
206+def watch(request, deployer):
207+ """Handle requests for watching a given deployment.
208+
209+ The deployment is identified in the request by the DeploymentId parameter.
210+ If the request is valid, the response will contain the WatcherId
211+ to be used to observe the deployment progress.
212+
213+ Request: 'Watch'.
214+ Parameters example: {'DeploymentId': 42}.
215+ """
216+ deployment_id = request.params.get('DeploymentId')
217+ if deployment_id is None:
218+ raise response(error='invalid request: invalid data parameters')
219+ # Retrieve a watcher identifier from the Deployer.
220+ watcher_id = deployer.watch(deployment_id)
221+ if watcher_id is None:
222+ raise response(error='invalid request: deployment not found')
223+ raise response({'WatcherId': watcher_id})
224+
225+
226+@gen.coroutine
227+@require_authenticated_user
228+def next(request, deployer):
229+ """Wait until a new deployment event is available to be sent to the client.
230+
231+ The request params must include a WatcherId value, used to identify the
232+ deployment being observed. If unsent changes are available, a response is
233+ immediately returned containing the changes. Otherwise, this views suspends
234+ its execution until a new change is notified by the Deployer.
235+
236+ Request: 'Next'.
237+ Parameters example: {'WatcherId': 47}.
238+ """
239+ watcher_id = request.params.get('WatcherId')
240+ if watcher_id is None:
241+ raise response(error='invalid request: invalid data parameters')
242+ # Wait for the Deployer to send changes.
243+ changes = yield deployer.next(watcher_id)
244+ if changes is None:
245+ raise response(error='invalid request: invalid watcher identifier')
246+ raise response({'Changes': changes})
247+
248+
249+@gen.coroutine
250+@require_authenticated_user
251+def status(request, deployer):
252+ """Return the current status of all the bundle deployments.
253+
254+ The 'Status' request does not receive parameters.
255+ """
256+ if request.params:
257+ params = ', '.join(request.params)
258+ error = 'invalid request: invalid data parameters: {}'.format(params)
259+ raise response(error=error)
260+ last_changes = deployer.status()
261+ raise response({'LastChanges': last_changes})
262
263=== added file 'server/guiserver/tests/bundles/test_utils.py'
264--- server/guiserver/tests/bundles/test_utils.py 1970-01-01 00:00:00 +0000
265+++ server/guiserver/tests/bundles/test_utils.py 2013-08-20 14:10:33 +0000
266@@ -0,0 +1,108 @@
267+# This file is part of the Juju GUI, which lets users view and manage Juju
268+# environments within a graphical interface (https://launchpad.net/juju-gui).
269+# Copyright (C) 2013 Canonical Ltd.
270+#
271+# This program is free software: you can redistribute it and/or modify it under
272+# the terms of the GNU Affero General Public License version 3, as published by
273+# the Free Software Foundation.
274+#
275+# This program is distributed in the hope that it will be useful, but WITHOUT
276+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
277+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
278+# Affero General Public License for more details.
279+#
280+# You should have received a copy of the GNU Affero General Public License
281+# along with this program. If not, see <http://www.gnu.org/licenses/>.
282+
283+"""Tests for the deployment utility functions."""
284+
285+import unittest
286+
287+from tornado import gen
288+from tornado.testing import(
289+ AsyncTestCase,
290+ ExpectLog,
291+ gen_test,
292+ LogTrapTestCase,
293+)
294+
295+from guiserver.bundles import utils
296+from guiserver.tests import helpers
297+
298+
299+class TestRequireAuthenticatedUser(
300+ helpers.BundlesTestMixin, LogTrapTestCase, AsyncTestCase):
301+
302+ deployer = 'fake-deployer'
303+
304+ def make_view(self):
305+ """Return a view to be used for tests.
306+
307+ The resulting callable must be called with a request object as first
308+ argument and with self.deployer as second argument.
309+ """
310+ @gen.coroutine
311+ @utils.require_authenticated_user
312+ def myview(request, deployer):
313+ """An example testing view."""
314+ self.assertEqual(self.deployer, deployer)
315+ raise utils.response(info='ok')
316+ return myview
317+
318+ @gen_test
319+ def test_authenticated(self):
320+ # The view is executed normally if the user is authenticated.
321+ view = self.make_view()
322+ request = self.make_view_request(is_authenticated=True)
323+ response = yield view(request, self.deployer)
324+ self.assertEqual({'Response': 'ok'}, response)
325+
326+ @gen_test
327+ def test_not_authenticated(self):
328+ # The view returns an error response if the user is not authenticated.
329+ view = self.make_view()
330+ request = self.make_view_request(is_authenticated=False)
331+ response = yield view(request, self.deployer)
332+ expected = {
333+ 'Response': {},
334+ 'Error': 'unauthorized access: no user logged in',
335+ }
336+ self.assertEqual(expected, response)
337+
338+ def test_wrap(self):
339+ # The decorated view looks like the wrapped function.
340+ view = self.make_view()
341+ self.assertEqual('myview', view.__name__)
342+ self.assertEqual('An example testing view.', view.__doc__)
343+
344+
345+class TestResponse(LogTrapTestCase, unittest.TestCase):
346+
347+ def assert_response(self, expected, response):
348+ """Ensure the given gen.Return instance contains the expected response.
349+ """
350+ self.assertIsInstance(response, gen.Return)
351+ self.assertEqual(expected, response.value)
352+
353+ def test_empty(self):
354+ # An empty response is correctly generated.
355+ expected = {'Response': {}}
356+ response = utils.response()
357+ self.assert_response(expected, response)
358+
359+ def test_success(self):
360+ # A success response is correctly generated.
361+ expected = {'Response': {'foo': 'bar'}}
362+ response = utils.response({'foo': 'bar'})
363+ self.assert_response(expected, response)
364+
365+ def test_failure(self):
366+ # A failure response is correctly generated.
367+ expected = {'Error': 'an error occurred', 'Response': {}}
368+ response = utils.response(error='an error occurred')
369+ self.assert_response(expected, response)
370+
371+ def test_log_failure(self):
372+ # An error log is written when a failure response is generated.
373+ with ExpectLog('', 'deployer: an error occurred', required=True):
374+ utils.response(error='an error occurred')
375
376=== added file 'server/guiserver/tests/bundles/test_views.py'
377--- server/guiserver/tests/bundles/test_views.py 1970-01-01 00:00:00 +0000
378+++ server/guiserver/tests/bundles/test_views.py 2013-08-20 14:10:33 +0000
379@@ -0,0 +1,259 @@
380+# This file is part of the Juju GUI, which lets users view and manage Juju
381+# environments within a graphical interface (https://launchpad.net/juju-gui).
382+# Copyright (C) 2013 Canonical Ltd.
383+#
384+# This program is free software: you can redistribute it and/or modify it under
385+# the terms of the GNU Affero General Public License version 3, as published by
386+# the Free Software Foundation.
387+#
388+# This program is distributed in the hope that it will be useful, but WITHOUT
389+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
390+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
391+# Affero General Public License for more details.
392+#
393+# You should have received a copy of the GNU Affero General Public License
394+# along with this program. If not, see <http://www.gnu.org/licenses/>.
395+
396+"""Tests for the bundle deployment views."""
397+
398+import mock
399+from tornado import concurrent
400+from tornado.testing import(
401+ AsyncTestCase,
402+ ExpectLog,
403+ gen_test,
404+ LogTrapTestCase,
405+)
406+
407+from guiserver.bundles import views
408+from guiserver.tests import helpers
409+
410+
411+class ViewsTestMixin(object):
412+ """Base helpers and common tests for all the view tests.
413+
414+ Subclasses must define a get_view() method returning the view function to
415+ be tested. Subclasses can also override the invalid_params and
416+ invalid_params_error attributes, used to test the view in the case the
417+ passed parameters are not valid.
418+ """
419+
420+ invalid_params = {'No-such': 'parameter'}
421+ invalid_params_error = 'invalid request: invalid data parameters'
422+
423+ def setUp(self):
424+ super(ViewsTestMixin, self).setUp()
425+ self.view = self.get_view()
426+ self.deployer = mock.Mock()
427+
428+ def make_future(self, result):
429+ """Create and return a Future containing the given result."""
430+ future = concurrent.Future()
431+ future.set_result(result)
432+ return future
433+
434+ @gen_test
435+ def test_not_authenticated(self):
436+ # An error response is returned if the user is not authenticated.
437+ request = self.make_view_request(is_authenticated=False)
438+ expected_log = 'deployer: unauthorized access: no user logged in'
439+ with ExpectLog('', expected_log, required=True):
440+ response = yield self.view(request, self.deployer)
441+ expected_response = {
442+ 'Response': {},
443+ 'Error': 'unauthorized access: no user logged in',
444+ }
445+ self.assertEqual(expected_response, response)
446+ # The Deployer methods have not been called.
447+ self.assertEqual(0, len(self.deployer.mock_calls))
448+
449+ @gen_test
450+ def test_invalid_parameters(self):
451+ # An error response is returned if the parameters in the request are
452+ # not valid.
453+ request = self.make_view_request(params=self.invalid_params)
454+ expected_log = 'deployer: {}'.format(self.invalid_params_error)
455+ with ExpectLog('', expected_log, required=True):
456+ response = yield self.view(request, self.deployer)
457+ expected_response = {
458+ 'Response': {},
459+ 'Error': self.invalid_params_error,
460+ }
461+ self.assertEqual(expected_response, response)
462+ # The Deployer methods have not been called.
463+ self.assertEqual(0, len(self.deployer.mock_calls))
464+
465+
466+class TestImportBundle(
467+ ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase,
468+ AsyncTestCase):
469+
470+ def get_view(self):
471+ return views.import_bundle
472+
473+ @gen_test
474+ def test_invalid_yaml(self):
475+ # An error response is returned if an invalid YAML encoded string is
476+ # passed.
477+ params = {'Name': 'bundle-name', 'YAML': 42}
478+ request = self.make_view_request(params=params)
479+ response = yield self.view(request, self.deployer)
480+ expected_response = {
481+ 'Response': {},
482+ 'Error': 'invalid request: invalid YAML contents: '
483+ "'int' object has no attribute 'read'",
484+ }
485+ self.assertEqual(expected_response, response)
486+ # The Deployer methods have not been called.
487+ self.assertEqual(0, len(self.deployer.mock_calls))
488+
489+ @gen_test
490+ def test_bundle_not_found(self):
491+ # An error response is returned if the requested bundle name is not
492+ # found in the bundle YAML contents.
493+ params = {'Name': 'no-such-bundle', 'YAML': 'mybundle: mycontents'}
494+ request = self.make_view_request(params=params)
495+ response = yield self.view(request, self.deployer)
496+ expected_response = {
497+ 'Response': {},
498+ 'Error': 'invalid request: bundle no-such-bundle not found',
499+ }
500+ self.assertEqual(expected_response, response)
501+ # The Deployer methods have not been called.
502+ self.assertEqual(0, len(self.deployer.mock_calls))
503+
504+ @gen_test
505+ def test_invalid_bundle(self):
506+ # An error response is returned if the bundle cannot be imported in the
507+ # current Juju environment.
508+ params = {'Name': 'mybundle', 'YAML': 'mybundle: mycontents'}
509+ request = self.make_view_request(params=params)
510+ # Simulate an error returned by the Deployer validate method.
511+ self.deployer.validate.return_value = self.make_future('an error')
512+ # Execute the view.
513+ response = yield self.view(request, self.deployer)
514+ expected_response = {
515+ 'Response': {},
516+ 'Error': 'invalid request: an error',
517+ }
518+ self.assertEqual(expected_response, response)
519+ # The Deployer validate method has been called.
520+ self.deployer.validate.assert_called_once_with(
521+ request.user, 'mybundle', 'mycontents')
522+
523+ @gen_test
524+ def test_success(self):
525+ # The response includes the deployment identifier.
526+ params = {'Name': 'mybundle', 'YAML': 'mybundle: mycontents'}
527+ request = self.make_view_request(params=params)
528+ # Set up the Deployer mock.
529+ self.deployer.validate.return_value = self.make_future(None)
530+ self.deployer.import_bundle.return_value = 42
531+ # Execute the view.
532+ response = yield self.view(request, self.deployer)
533+ expected_response = {'Response': {'DeploymentId': 42}}
534+ self.assertEqual(expected_response, response)
535+ # Ensure the Deployer methods have been correctly called.
536+ args = (request.user, 'mybundle', 'mycontents')
537+ self.deployer.validate.assert_called_once_with(*args)
538+ self.deployer.import_bundle.assert_called_once_with(*args)
539+
540+
541+class TestWatch(
542+ ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase,
543+ AsyncTestCase):
544+
545+ def get_view(self):
546+ return views.watch
547+
548+ @gen_test
549+ def test_deployment_not_found(self):
550+ # An error response is returned if the deployment identifier is not
551+ # valid.
552+ request = self.make_view_request(params={'DeploymentId': 42})
553+ # Set up the Deployer mock.
554+ self.deployer.watch.return_value = None
555+ # Execute the view.
556+ response = yield self.view(request, self.deployer)
557+ expected_response = {
558+ 'Response': {},
559+ 'Error': 'invalid request: deployment not found',
560+ }
561+ self.assertEqual(expected_response, response)
562+ # Ensure the Deployer methods have been correctly called.
563+ self.deployer.watch.assert_called_once_with(42)
564+
565+ @gen_test
566+ def test_success(self):
567+ # The response includes the watcher identifier.
568+ request = self.make_view_request(params={'DeploymentId': 42})
569+ # Set up the Deployer mock.
570+ self.deployer.watch.return_value = 47
571+ # Execute the view.
572+ response = yield self.view(request, self.deployer)
573+ expected_response = {'Response': {'WatcherId': 47}}
574+ self.assertEqual(expected_response, response)
575+ # Ensure the Deployer methods have been correctly called.
576+ self.deployer.watch.assert_called_once_with(42)
577+
578+
579+class TestNext(
580+ ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase,
581+ AsyncTestCase):
582+
583+ def get_view(self):
584+ return views.next
585+
586+ @gen_test
587+ def test_invalid_watcher_identifier(self):
588+ # An error response is returned if the watcher identifier is not valid.
589+ request = self.make_view_request(params={'WatcherId': 42})
590+ # Set up the Deployer mock.
591+ self.deployer.next.return_value = self.make_future(None)
592+ # Execute the view.
593+ response = yield self.view(request, self.deployer)
594+ expected_response = {
595+ 'Response': {},
596+ 'Error': 'invalid request: invalid watcher identifier',
597+ }
598+ self.assertEqual(expected_response, response)
599+ # Ensure the Deployer methods have been correctly called.
600+ self.deployer.next.assert_called_once_with(42)
601+
602+ @gen_test
603+ def test_success(self):
604+ # The response includes the deployment changes.
605+ request = self.make_view_request(params={'WatcherId': 42})
606+ # Set up the Deployer mock.
607+ changes = ['change1', 'change2']
608+ self.deployer.next.return_value = self.make_future(changes)
609+ # Execute the view.
610+ response = yield self.view(request, self.deployer)
611+ expected_response = {'Response': {'Changes': changes}}
612+ self.assertEqual(expected_response, response)
613+ # Ensure the Deployer methods have been correctly called.
614+ self.deployer.next.assert_called_once_with(42)
615+
616+
617+class TestStatus(
618+ ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase,
619+ AsyncTestCase):
620+
621+ invalid_params_error = 'invalid request: invalid data parameters: No-such'
622+
623+ def get_view(self):
624+ return views.status
625+
626+ @gen_test
627+ def test_success(self):
628+ # The response includes the watcher identifier.
629+ request = self.make_view_request()
630+ # Set up the Deployer mock.
631+ last_changes = ['change1', 'change2']
632+ self.deployer.status.return_value = last_changes
633+ # Execute the view.
634+ response = yield self.view(request, self.deployer)
635+ expected_response = {'Response': {'LastChanges': last_changes}}
636+ self.assertEqual(expected_response, response)
637+ # Ensure the Deployer methods have been correctly called.
638+ self.deployer.status.assert_called_once_with()
639
640=== modified file 'server/guiserver/tests/helpers.py'
641--- server/guiserver/tests/helpers.py 2013-08-19 13:56:31 +0000
642+++ server/guiserver/tests/helpers.py 2013-08-20 14:10:33 +0000
643@@ -18,6 +18,7 @@
644
645 import json
646
647+import mock
648 from tornado import websocket
649 import yaml
650
651@@ -179,6 +180,20 @@
652 all_contents = yaml.load(self.bundle)
653 return all_contents.items()[0]
654
655+ def make_view_request(self, params=None, is_authenticated=True):
656+ """Create and return a mock request to be passed to bundle views.
657+
658+ The resulting request contains the given parameters and a
659+ guiserver.auth.User instance.
660+ If is_authenticated is True, the user in the request is logged in.
661+ """
662+ if params is None:
663+ params = {}
664+ user = auth.User(
665+ username='user', password='passwd',
666+ is_authenticated=is_authenticated)
667+ return mock.Mock(params=params, user=user)
668+
669
670 class WSSTestMixin(object):
671 """Add some helper methods for testing secure WebSocket handlers."""

Subscribers

People subscribed via source and target branches