Fix transaction behavior for update_subscriptions_backend().

This commit extracts compose_views() from update_subscriptions_backend(),
and it implements the correct behavior for forcing transactions to roll
back, which is to raise an exception.

There were really three steps in this commit:

- Extract buggy code to compose_views().
- Add tests on compose_views().
- Fix bugs exposed by the new tests by converting errors to exceptions.
This commit is contained in:
Steve Howell 2016-09-12 08:21:49 -07:00 committed by Tim Abbott
parent 82d03d603a
commit 4c5eb3d06e
3 changed files with 69 additions and 10 deletions

View File

@ -288,6 +288,7 @@ def build_custom_checkers(by_lang):
('zerver/lib/actions.py', 'raise JsonableError(e.messages[0])'), ('zerver/lib/actions.py', 'raise JsonableError(e.messages[0])'),
('zerver/views/messages.py', 'raise JsonableError(error)'), ('zerver/views/messages.py', 'raise JsonableError(error)'),
('zerver/lib/request.py', 'raise JsonableError(error)'), ('zerver/lib/request.py', 'raise JsonableError(error)'),
('zerver/views/streams.py', 'raise JsonableError(response.content)'),
]), ]),
'description': 'Argument to JsonableError should be a literal string enclosed by _()'}, 'description': 'Argument to JsonableError should be a literal string enclosed by _()'},
{'pattern': '([a-zA-Z0-9_]+)=REQ\([\'"]\\1[\'"]', {'pattern': '([a-zA-Z0-9_]+)=REQ\([\'"]\\1[\'"]',

View File

@ -3,6 +3,9 @@ from __future__ import absolute_import
from typing import Any, Dict, List, Mapping, Optional, Sequence from typing import Any, Dict, List, Mapping, Optional, Sequence
from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _
from zerver.lib import cache from zerver.lib import cache
from zerver.lib.test_helpers import ( from zerver.lib.test_helpers import (
@ -13,6 +16,11 @@ from zerver.decorator import (
JsonableError JsonableError
) )
from zerver.lib.response import (
json_error,
json_success,
)
from zerver.lib.test_runner import ( from zerver.lib.test_runner import (
slow slow
) )
@ -30,6 +38,10 @@ from zerver.lib.actions import (
get_user_profile_by_email, set_default_streams, get_subscription get_user_profile_by_email, set_default_streams, get_subscription
) )
from zerver.views.streams import (
compose_views
)
from django.http import HttpResponse from django.http import HttpResponse
import mock import mock
import random import random
@ -881,6 +893,36 @@ class SubscriptionRestApiTest(ZulipTestCase):
self.assert_json_error(result, self.assert_json_error(result,
"Stream name (%s) too long." % (long_stream_name,)) "Stream name (%s) too long." % (long_stream_name,))
def test_compose_views_rollback(self):
# type: () -> None
'''
The compose_views function() is used under the hood by
update_subscriptions_backend. It's a pretty simple method in terms of
control flow, but it uses a Django rollback, which may make it brittle
code when we upgrade Django. We test the functions's rollback logic
here with a simple scenario to avoid false positives related to
subscription complications.
'''
user_profile = get_user_profile_by_email('hamlet@zulip.com')
user_profile.full_name = 'Hamlet'
user_profile.save()
def method1 (req, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
user_profile.full_name = 'Should not be committed'
user_profile.save()
return json_success({})
def method2(req, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
return json_error(_('random failure'))
with self.assertRaises(JsonableError):
compose_views(None, user_profile, [(method1, {}), (method2, {})])
user_profile = get_user_profile_by_email('hamlet@zulip.com')
self.assertEqual(user_profile.full_name, 'Hamlet')
class SubscriptionAPITest(ZulipTestCase): class SubscriptionAPITest(ZulipTestCase):
def setUp(self): def setUp(self):

View File

@ -1,5 +1,5 @@
from __future__ import absolute_import from __future__ import absolute_import
from typing import Any, Optional, Tuple, List, Set, Iterable, Mapping, Callable from typing import Any, Optional, Tuple, List, Set, Iterable, Mapping, Callable, Dict
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf import settings from django.conf import settings
@ -181,9 +181,8 @@ def list_subscriptions_backend(request, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse # type: (HttpRequest, UserProfile) -> HttpResponse
return json_success({"subscriptions": gather_subscriptions(user_profile)[0]}) return json_success({"subscriptions": gather_subscriptions(user_profile)[0]})
FuncItPair = Tuple[Callable[..., HttpResponse], Iterable[Any]] FuncKwargPair = Tuple[Callable[..., HttpResponse], Dict[str, Iterable[Any]]]
@transaction.atomic
@has_request_variables @has_request_variables
def update_subscriptions_backend(request, user_profile, def update_subscriptions_backend(request, user_profile,
delete=REQ(validator=check_list(check_string), default=[]), delete=REQ(validator=check_list(check_string), default=[]),
@ -192,14 +191,31 @@ def update_subscriptions_backend(request, user_profile,
if not add and not delete: if not add and not delete:
return json_error(_('Nothing to do. Specify at least one of "add" or "delete".')) return json_error(_('Nothing to do. Specify at least one of "add" or "delete".'))
method_kwarg_pairs = [
(add_subscriptions_backend, dict(streams_raw=add)),
(remove_subscriptions_backend, dict(streams_raw=delete))
] # type: List[FuncKwargPair]
return compose_views(request, user_profile, method_kwarg_pairs)
def compose_views(request, user_profile, method_kwarg_pairs):
# type: (HttpRequest, UserProfile, List[FuncKwargPair]) -> HttpResponse
'''
This takes a series of view methods from method_kwarg_pairs and calls
them in sequence, and it smushes all the json results into a single
response when everything goes right. (This helps clients avoid extra
latency hops.) It rolls back the transaction when things go wrong in
any one of the composed methods.
TODO: Move this a utils-like module if we end up using it more widely.
'''
json_dict = {} # type: Dict[str, Any] json_dict = {} # type: Dict[str, Any]
method_items_pairs = ((add_subscriptions_backend, add), (remove_subscriptions_backend, delete)) # type: Tuple[FuncItPair, FuncItPair] with transaction.atomic():
for method, items in method_items_pairs: for method, kwargs in method_kwarg_pairs:
response = method(request, user_profile, streams_raw=items) response = method(request, user_profile, **kwargs)
if response.status_code != 200: if response.status_code != 200:
transaction.rollback() raise JsonableError(response.content)
return response json_dict.update(ujson.loads(response.content))
json_dict.update(ujson.loads(response.content))
return json_success(json_dict) return json_success(json_dict)
@authenticated_json_post_view @authenticated_json_post_view