From 303b328863f94e9f893a6e174a36a423d5498549 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 31 Oct 2019 09:42:22 +0100
Subject: [PATCH] api: return err_desc & reason in case of error (#24025)

---
 chrono/api/utils.py     |  26 +++++++++
 chrono/api/views.py     |  67 +++++++++++++++++-----
 tests/test_api.py       | 124 ++++++++++++++++++++++++++++++----------
 tests/test_api_utils.py |  15 +++++
 4 files changed, 186 insertions(+), 46 deletions(-)
 create mode 100644 chrono/api/utils.py
 create mode 100644 tests/test_api_utils.py

diff --git a/chrono/api/utils.py b/chrono/api/utils.py
new file mode 100644
index 0000000..f474636
--- /dev/null
+++ b/chrono/api/utils.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# chrono - agendas system
+# Copyright (C) 2019  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from rest_framework.response import Response as DRFResponse
+
+
+class Response(DRFResponse):
+    def __init__(self, data=None, *args, **kwargs):
+        # add reason for compatibility (https://dev.entrouvert.org/issues/24025)
+        if data is not None and 'err_class' in data:
+            data['reason'] = data['err_class']
+        super(Response, self).__init__(data=data, *args, **kwargs)
diff --git a/chrono/api/views.py b/chrono/api/views.py
index 15cb364..bb63be9 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -24,11 +24,13 @@ from django.shortcuts import get_object_or_404
 from django.utils.dateparse import parse_date
 from django.utils.encoding import force_text
 from django.utils.timezone import now, make_aware, localtime
+from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import permissions, serializers, status
-from rest_framework.response import Response
+
 from rest_framework.views import APIView
 
+from chrono.api.utils import Response
 from ..agendas.models import (Agenda, Event, Booking, MeetingType,
                               TimePeriod, Desk)
 from ..interval import Intervals
@@ -351,7 +353,8 @@ class Fillslots(APIView):
         if not serializer.is_valid():
             return Response({
                 'err': 1,
-                'reason': 'invalid payload',
+                'err_class': 'invalid payload',
+                'err_desc': _('invalid payload'),
                 'errors': serializer.errors
             }, status=status.HTTP_400_BAD_REQUEST)
         payload = serializer.validated_data
@@ -361,7 +364,8 @@ class Fillslots(APIView):
         if not slots:
             return Response({
                 'err': 1,
-                'reason': 'slots list cannot be empty',
+                'err_class': 'slots list cannot be empty',
+                'err_desc': _('slots list cannot be empty'),
             }, status=status.HTTP_400_BAD_REQUEST)
 
         if 'count' in payload:
@@ -373,7 +377,8 @@ class Fillslots(APIView):
             except ValueError:
                 return Response({
                     'err': 1,
-                    'reason': 'invalid value for count (%s)' % request.query_params['count'],
+                    'err_class': 'invalid value for count (%s)' % request.query_params['count'],
+                    'err_desc': _('invalid value for count (%s)') % request.query_params['count'],
                 }, status=status.HTTP_400_BAD_REQUEST)
         else:
             places_count = 1
@@ -381,7 +386,8 @@ class Fillslots(APIView):
         if places_count <= 0:
             return Response({
                 'err': 1,
-                'reason': 'count cannot be less than or equal to zero'
+                'err_class': 'count cannot be less than or equal to zero',
+                'err_desc': _('count cannot be less than or equal to zero'),
             }, status=status.HTTP_400_BAD_REQUEST)
 
         to_cancel_booking = None
@@ -392,7 +398,8 @@ class Fillslots(APIView):
             except (ValueError, TypeError):
                 return Response({
                     'err': 1,
-                    'reason': 'cancel_booking_id is not an integer'
+                    'err_class': 'cancel_booking_id is not an integer',
+                    'err_desc': _('cancel_booking_id is not an integer'),
                 }, status=status.HTTP_400_BAD_REQUEST)
 
         if cancel_booking_id is not None:
@@ -409,7 +416,11 @@ class Fillslots(APIView):
                 cancel_error = 'cancel booking: booking does no exist'
 
             if cancel_error:
-                return Response({'err': 1, 'reason': cancel_error})
+                return Response({
+                    'err': 1,
+                    'err_class': cancel_error,
+                    'err_desc': _(cancel_error),
+                })
 
         extra_data = {}
         for k, v in request.data.items():
@@ -429,12 +440,14 @@ class Fillslots(APIView):
                 except ValueError:
                     return Response({
                         'err': 1,
-                        'reason': 'invalid slot: %s' % slot,
+                        'err_class': 'invalid slot: %s' % slot,
+                        'err_desc': _('invalid slot: %s') % slot,
                     }, status=status.HTTP_400_BAD_REQUEST)
                 if meeting_type_id_ != meeting_type_id:
                     return Response({
                         'err': 1,
-                        'reason': 'all slots must have the same meeting type id (%s)' % meeting_type_id
+                        'err_class': 'all slots must have the same meeting type id (%s)' % meeting_type_id,
+                        'err_desc': _('all slots must have the same meeting type id (%s)') % meeting_type_id,
                     }, status=status.HTTP_400_BAD_REQUEST)
                 datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
 
@@ -451,7 +464,11 @@ class Fillslots(APIView):
                     available_desk = Desk.objects.get(id=available_desk_id)
                     break
             else:
-                return Response({'err': 1, 'reason': 'no more desk available'})
+                return Response({
+                    'err': 1,
+                    'err_class': 'no more desk available',
+                    'err_desc': _('no more desk available'),
+                })
 
             # all datetimes are free, book them in order
             datetimes = list(datetimes)
@@ -478,10 +495,18 @@ class Fillslots(APIView):
                     # in the waiting list.
                     in_waiting_list = True
                     if (event.waiting_list + places_count) > event.waiting_list_places:
-                        return Response({'err': 1, 'reason': 'sold out'})
+                        return Response({
+                            'err': 1,
+                            'err_class': 'sold out',
+                            'err_desc': _('sold out'),
+                        })
             else:
                 if (event.booked_places + places_count) > event.places:
-                    return Response({'err': 1, 'reason': 'sold out'})
+                    return Response({
+                        'err': 1,
+                        'err_class': 'sold out',
+                        'err_desc': _('sold out')
+                    })
 
         with transaction.atomic():
             if to_cancel_booking:
@@ -574,7 +599,11 @@ class CancelBooking(APIView):
     def post(self, request, booking_pk=None, format=None):
         booking = get_object_or_404(Booking, id=booking_pk)
         if booking.cancellation_datetime:
-            response = {'err': 1, 'reason': 'already cancelled'}
+            response = {
+                'err': 1,
+                'err_class': 'already cancelled',
+                'err_desc': _('already cancelled'),
+            }
             return Response(response)
         booking.cancel()
         response = {'err': 0, 'booking_id': booking.id}
@@ -595,10 +624,18 @@ class AcceptBooking(APIView):
     def post(self, request, booking_pk=None, format=None):
         booking = get_object_or_404(Booking, id=booking_pk)
         if booking.cancellation_datetime:
-            response = {'err': 1, 'reason': 'booking is cancelled'}
+            response = {
+                'err': 1,
+                'err_class': 'booking is cancelled',
+                'err_desc': _('booking is cancelled'),
+            }
             return Response(response)
         if not booking.in_waiting_list:
-            response = {'err': 2, 'reason': 'booking is not in waiting list'}
+            response = {
+                'err': 2,
+                'err_class': 'booking is not in waiting list',
+                'err_desc': _('booking is not in waiting list'),
+            }
             return Response(response)
         booking.accept()
         response = {'err': 0, 'booking_id': booking.id}
diff --git a/tests/test_api.py b/tests/test_api.py
index 78e6d40..3b545da 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -316,12 +316,16 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda,
     app.authorization = ('Basic', ('john.doe', 'password'))
     resp = app.post(fillslot_url)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'no more desk available'
+    assert resp.json['reason'] == 'no more desk available'  # legacy
+    assert resp.json['err_class'] == 'no more desk available'
+    assert resp.json['err_desc'] == 'no more desk available'
     # booking the two slots fails too
     fillslots_url = '/api/agenda/%s/fillslots/' % meeting_type.agenda.slug
     resp = app.post(fillslots_url, params={'slots': two_slots})
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'no more desk available'
+    assert resp.json['reason'] == 'no more desk available'  # legacy
+    assert resp.json['err_class'] == 'no more desk available'
+    assert resp.json['err_desc'] == 'no more desk available'
 
 def test_booking_api(app, some_data, user):
     agenda = Agenda.objects.filter(label=u'Foo bar')[0]
@@ -376,7 +380,9 @@ def test_booking_api(app, some_data, user):
     resp = app.post_json('/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id),
             params={'user_name': {'foo': 'bar'}}, status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'invalid payload'
+    assert resp.json['reason'] == 'invalid payload'  # legacy
+    assert resp.json['err_class'] == 'invalid payload'
+    assert resp.json['err_desc'] == 'invalid payload'
     assert len(resp.json['errors']) == 1
     assert 'user_name' in resp.json['errors']
 
@@ -538,21 +544,29 @@ def test_booking_api_fillslots(app, some_data, user):
             params={'slots': events_ids,
                     'user_name': {'foo': 'bar'}}, status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'invalid payload'
+    assert resp.json['reason'] == 'invalid payload'  # legacy
+    assert resp.json['err_class'] == 'invalid payload'
+    assert resp.json['err_desc'] == 'invalid payload'
     assert len(resp.json['errors']) == 1
     assert 'user_name' in resp.json['errors']
 
     # empty or missing slots
     resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, params={'slots': []}, status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'slots list cannot be empty'
+    assert resp.json['reason'] == 'slots list cannot be empty'  # legacy
+    assert resp.json['err_class'] == 'slots list cannot be empty'
+    assert resp.json['err_desc'] == 'slots list cannot be empty'
     resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'slots list cannot be empty'
+    assert resp.json['reason'] == 'slots list cannot be empty'  # legacy
+    assert resp.json['err_class'] == 'slots list cannot be empty'
+    assert resp.json['err_desc'] == 'slots list cannot be empty'
     # invalid slots format
     resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, params={'slots': 'foobar'}, status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'invalid payload'
+    assert resp.json['reason'] == 'invalid payload'  # legacy
+    assert resp.json['err_class'] == 'invalid payload'
+    assert resp.json['err_desc'] == 'invalid payload'
     assert len(resp.json['errors']) == 1
     assert 'slots' in resp.json['errors']
 
@@ -589,7 +603,9 @@ def test_booking_api_meeting(app, meetings_agenda, user):
     # try booking the same timeslot
     resp2 = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
     assert resp2.json['err'] == 1
-    assert resp2.json['reason'] == 'no more desk available'
+    assert resp2.json['reason'] == 'no more desk available'  # legacy
+    assert resp2.json['err_class'] == 'no more desk available'
+    assert resp2.json['err_desc'] == 'no more desk available'
 
     # try booking another timeslot
     event_id = resp.json['data'][3]['id']
@@ -619,13 +635,17 @@ def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
     # try booking the same timeslots
     resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots})
     assert resp2.json['err'] == 1
-    assert resp2.json['reason'] == 'no more desk available'
+    assert resp2.json['reason'] == 'no more desk available'  # legacy
+    assert resp2.json['err_class'] == 'no more desk available'
+    assert resp2.json['err_desc'] == 'no more desk available'
 
     # try booking partially free timeslots (one free, one busy)
     nonfree_slots = [resp.json['data'][0]['id'], resp.json['data'][2]['id']]
     resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': nonfree_slots})
     assert resp2.json['err'] == 1
-    assert resp2.json['reason'] == 'no more desk available'
+    assert resp2.json['reason'] == 'no more desk available'  # legacy
+    assert resp2.json['err_class'] == 'no more desk available'
+    assert resp2.json['err_desc'] == 'no more desk available'
 
     # booking other free timeslots
     free_slots = [resp.json['data'][3]['id'], resp.json['data'][2]['id']]
@@ -647,7 +667,9 @@ def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
                     params={'slots': impossible_slots},
                     status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'all slots must have the same meeting type id (1)'
+    assert resp.json['reason'] == 'all slots must have the same meeting type id (1)'  # legacy
+    assert resp.json['err_class'] == 'all slots must have the same meeting type id (1)'
+    assert resp.json['err_desc'] == 'all slots must have the same meeting type id (1)'
 
 def test_booking_api_meeting_across_daylight_saving_time(app, meetings_agenda, user):
     meetings_agenda.maximal_booking_delay = 365
@@ -762,7 +784,9 @@ def test_booking_api_with_cancel_booking(app, some_data, user):
         params={'cancel_booking_id': first_booking.pk}
     )
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'cancel booking: booking already cancelled'
+    assert resp.json['reason'] == 'cancel booking: booking already cancelled'  # legacy
+    assert resp.json['err_class'] == 'cancel booking: booking already cancelled'
+    assert resp.json['err_desc'] == 'cancel booking: booking already cancelled'
     assert Booking.objects.count() == 2
 
     # Cancelling a non existent booking returns an error
@@ -771,7 +795,9 @@ def test_booking_api_with_cancel_booking(app, some_data, user):
         params={'cancel_booking_id': '-1'}
     )
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'cancel booking: booking does no exist'
+    assert resp.json['reason'] == 'cancel booking: booking does no exist'  # legacy
+    assert resp.json['err_class'] == 'cancel booking: booking does no exist'
+    assert resp.json['err_desc'] == 'cancel booking: booking does no exist'
     assert Booking.objects.count() == 2
 
     # Cancelling booking with different count than new booking
@@ -788,7 +814,9 @@ def test_booking_api_with_cancel_booking(app, some_data, user):
         params={'cancel_booking_id': booking_id, 'count': 1}
     )
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'cancel booking: count is different'
+    assert resp.json['reason'] == 'cancel booking: count is different'  # legacy
+    assert resp.json['err_class'] == 'cancel booking: count is different'
+    assert resp.json['err_desc'] == 'cancel booking: count is different'
     assert Booking.objects.count() == 4
 
     # cancel_booking_id must be an integer
@@ -887,7 +915,9 @@ def test_soldout(app, some_data, user):
     app.authorization = ('Basic', ('john.doe', 'password'))
     resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event.id), status=200)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
 
 def test_status(app, some_data, user):
     agenda_id = Agenda.objects.filter(label=u'Foo bar')[0].id
@@ -980,7 +1010,9 @@ def test_waiting_list_booking(app, some_data, user):
     app.authorization = ('Basic', ('john.doe', 'password'))
     resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event.id), status=200)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
 
 def test_accept_booking(app, some_data, user):
     agenda_id = Agenda.objects.filter(label=u'Foo bar')[0].id
@@ -1025,17 +1057,23 @@ def test_multiple_booking_api(app, some_data, user):
     app.authorization = ('Basic', ('john.doe', 'password'))
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=NaN' % (agenda.slug, event.id), status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == "invalid value for count (NaN)"
+    assert resp.json['reason'] == "invalid value for count (NaN)"  # legacy
+    assert resp.json['err_class'] == "invalid value for count (NaN)"
+    assert resp.json['err_desc'] == "invalid value for count (NaN)"
 
     app.authorization = ('Basic', ('john.doe', 'password'))
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=0' % (agenda.slug, event.id), status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == "count cannot be less than or equal to zero"
+    assert resp.json['reason'] == "count cannot be less than or equal to zero"  # legacy
+    assert resp.json['err_class'] == "count cannot be less than or equal to zero"
+    assert resp.json['err_desc'] == "count cannot be less than or equal to zero"
 
     app.authorization = ('Basic', ('john.doe', 'password'))
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=-3' % (agenda.slug, event.id), status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == "count cannot be less than or equal to zero"
+    assert resp.json['reason'] == "count cannot be less than or equal to zero"  # legacy
+    assert resp.json['err_class'] == "count cannot be less than or equal to zero"
+    assert resp.json['err_desc'] == "count cannot be less than or equal to zero"
 
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=3' % (agenda.slug, event.id))
     Booking.objects.get(id=resp.json['booking_id'])
@@ -1062,7 +1100,9 @@ def test_multiple_booking_api(app, some_data, user):
     # check waiting list overflow
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=5' % (agenda.slug, event.id))
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
     assert Event.objects.get(id=event.id).booked_places == 2
     assert Event.objects.get(id=event.id).waiting_list == 5
 
@@ -1079,7 +1119,9 @@ def test_multiple_booking_api(app, some_data, user):
 
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=5' % (agenda.slug, event.id))
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
 
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=3' % (agenda.slug, event.id))
     assert resp.json['err'] == 0
@@ -1088,7 +1130,9 @@ def test_multiple_booking_api(app, some_data, user):
 
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=3' % (agenda.slug, event.id))
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
 
     resp = app.post('/api/agenda/%s/fillslot/%s/?count=2' % (agenda.slug, event.id))
     assert resp.json['err'] == 0
@@ -1106,12 +1150,16 @@ def test_multiple_booking_api_fillslots(app, some_data, user):
     app.authorization = ('Basic', ('john.doe', 'password'))
     resp = app.post('/api/agenda/%s/fillslots/?count=NaN' % agenda.slug, params={'slots': slots}, status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == "invalid value for count (NaN)"
+    assert resp.json['reason'] == "invalid value for count (NaN)"  # legacy
+    assert resp.json['err_class'] == "invalid value for count (NaN)"
+    assert resp.json['err_desc'] == "invalid value for count (NaN)"
 
     resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug,
                     params={'slots': slots, 'count': 'NaN'}, status=400)
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == "invalid payload"
+    assert resp.json['reason'] == "invalid payload"  # legacy
+    assert resp.json['err_class'] == "invalid payload"
+    assert resp.json['err_desc'] == "invalid payload"
     assert 'count' in resp.json['errors']
 
     # get 3 places on 2 slots
@@ -1155,7 +1203,9 @@ def test_multiple_booking_api_fillslots(app, some_data, user):
     resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug,
                     params={'slots': slots, 'count': 5})
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
     for event in events:
         assert Event.objects.get(id=event.id).booked_places == 2
         assert Event.objects.get(id=event.id).waiting_list == 5
@@ -1176,7 +1226,9 @@ def test_multiple_booking_api_fillslots(app, some_data, user):
     resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug,
                     params={'slots': slots, 'count': 5})
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
 
     resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug,
                     params={'slots': slots, 'count': 3})
@@ -1188,7 +1240,9 @@ def test_multiple_booking_api_fillslots(app, some_data, user):
     resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug,
                     params={'slots': slots, 'count': 3})
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'sold out'
+    assert resp.json['reason'] == 'sold out'  # legacy
+    assert resp.json['err_class'] == 'sold out'
+    assert resp.json['err_desc'] == 'sold out'
 
     resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug,
                     params={'slots': slots, 'count': '2'})
@@ -1299,7 +1353,9 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
     resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 2
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'no more desk available'
+    assert resp.json['reason'] == 'no more desk available'  # legacy
+    assert resp.json['err_class'] == 'no more desk available'
+    assert resp.json['err_desc'] == 'no more desk available'
 
     # cancel first booking and retry
     resp = app.post(cancel_url)
@@ -1336,7 +1392,9 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
     # try booking the same timeslot again and fail
     resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'no more desk available'
+    assert resp.json['reason'] == 'no more desk available'  # legacy
+    assert resp.json['err_class'] == 'no more desk available'
+    assert resp.json['err_desc'] == 'no more desk available'
 
     # fill the agenda and make sure big O is O(1)
     for idx, event_data in enumerate(resp2.json['data'][2:10]):
@@ -1387,7 +1445,9 @@ def test_agenda_meeting_api_fillslots_multiple_desks(app, meetings_agenda, user)
     # try booking again: no desk available
     resp = app.post(fillslots_url, params={'slots': slots})
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'no more desk available'
+    assert resp.json['reason'] == 'no more desk available'  # legacy
+    assert resp.json['err_class'] == 'no more desk available'
+    assert resp.json['err_desc'] == 'no more desk available'
     assert get_free_places() == start_free_places - len(slots)
 
     # cancel desk 1 booking
@@ -1406,7 +1466,9 @@ def test_agenda_meeting_api_fillslots_multiple_desks(app, meetings_agenda, user)
     # try booking the 3 slots again: no desk available, one slot is not fully available
     resp = app.post(fillslots_url, params={'slots': slots})
     assert resp.json['err'] == 1
-    assert resp.json['reason'] == 'no more desk available'
+    assert resp.json['reason'] == 'no more desk available'  # legacy
+    assert resp.json['err_class'] == 'no more desk available'
+    assert resp.json['err_desc'] == 'no more desk available'
 
     # cancel last signel slot booking, desk1 will be free
     resp = app.post(cancel_url)
diff --git a/tests/test_api_utils.py b/tests/test_api_utils.py
new file mode 100644
index 0000000..365fa09
--- /dev/null
+++ b/tests/test_api_utils.py
@@ -0,0 +1,15 @@
+import pytest
+
+from chrono.api.utils import Response
+
+
+@pytest.mark.parametrize('data, expected', [
+    (None, None),
+    ({}, {}),
+    ({'reason': 'foo'}, {'reason': 'foo'}),
+    ({'err_class': 'foo'}, {'err_class': 'foo', 'reason': 'foo'}),
+    ({'bar': 'foo'}, {'bar': 'foo'}),
+])
+def test_response_data(data, expected):
+    resp = Response(data=data)
+    assert resp.data == expected
-- 
2.20.1

