source: OpenWorkouts-current/ow/tests/views/test_workout.py @ e52a502

current
Last change on this file since e52a502 was e52a502, checked in by Borja Lopez <borja@…>, 5 years ago

(#73) Fixed broken "add workout manually" when provided with a distance value
with decimals.

  • Property mode set to 100644
File size: 21.1 KB
Line 
1import os
2import json
3from io import BytesIO
4from datetime import datetime, timedelta, timezone
5from cgi import FieldStorage
6from unittest.mock import Mock, patch, PropertyMock
7from decimal import Decimal
8
9import pytest
10
11from pyramid.testing import DummyRequest
12from pyramid.httpexceptions import HTTPFound, HTTPNotFound
13from pyramid.response import Response
14
15from webob.multidict import MultiDict
16
17from ow.models.root import OpenWorkouts
18from ow.models.user import User
19from ow.models.workout import Workout
20from ow.schemas.workout import (
21    ManualWorkoutSchema,
22    UploadedWorkoutSchema,
23    UpdateWorkoutSchema,
24    )
25import ow.views.workout as workout_views
26
27
28class TestWorkoutViews(object):
29
30    # paths to gpx files we can use for testing, used in some of the tests
31    # as py.test fixtures
32    gpx_filenames = (
33        # GPX 1.0 file, no extensions
34        'fixtures/20131013.gpx',
35        # GPX 1.0 file, no extensions, missing elevation
36        'fixtures/20131013-without-elevation.gpx',
37        # GPX 1.1 file with extensions
38        'fixtures/20160129-with-extensions.gpx',
39        )
40
41    def open_uploaded_file(self, path):
42        """
43        Open the uploaded tracking file fixture from disk
44        """
45        uploaded_file_path = os.path.join(
46            os.path.dirname(os.path.dirname(__file__)), path)
47        uploaded_file = open(uploaded_file_path, 'r')
48        return uploaded_file
49
50    def close_uploaded_file(self, uploaded_file):
51        """
52        Close the opened uploaded tracking file
53        """
54        uploaded_file.close()
55
56    def create_filestorage(self, uploaded_file):
57        """
58        Create a FileStorage instance from an open uploaded tracking file,
59        suitable for testing file uploads later
60        """
61        storage = FieldStorage()
62        storage.filename = os.path.basename(uploaded_file.name)
63        storage.file = BytesIO(uploaded_file.read().encode('utf-8'))
64        storage.name = os.path.basename(uploaded_file.name)
65        # This prevents FormEncode validator from thinking we are providing
66        # more than one file for the upload, which crashes the tests
67        storage.list = None
68        return storage
69
70    @pytest.fixture
71    def root(self):
72        root = OpenWorkouts()
73        root['john'] = User(firstname='John', lastname='Doe',
74                            email='john.doe@example.net')
75        root['john'].password = 's3cr3t'
76        workout = Workout(
77            start=datetime(2015, 6, 28, 12, 55, tzinfo=timezone.utc),
78            duration=timedelta(minutes=60),
79            distance=30
80        )
81        root['john'].add_workout(workout)
82        return root
83
84    @pytest.fixture
85    def dummy_request(self, root):
86        request = DummyRequest()
87        request.root = root
88        return request
89
90    @pytest.fixture
91    def valid_post_request(self, root):
92        request = DummyRequest()
93        request.root = root
94        request.method = 'POST'
95        request.POST = MultiDict({
96            'start_date': '21/12/2015',
97            'start_time': '8:30',
98            'duration_hours': '3',
99            'duration_minutes': '30',
100            'duration_seconds': '20',
101            'distance': '10',
102            'submit': True,
103            })
104        return request
105
106    def test_add_workout_manually_get(self, dummy_request):
107        """
108        Test the view that renders the "add workout manually" form
109        """
110        request = dummy_request
111        user = request.root['john']
112        response = workout_views.add_workout_manually(user, request)
113        assert 'form' in response
114        assert len(response['form'].form.errors) == 0
115        assert isinstance(response['form'].form.schema, ManualWorkoutSchema)
116
117    def test_add_workout_manually_post_invalid(self, dummy_request):
118        """
119        POST request to add a workout manually, without providing the required
120        form data.
121        """
122        request = dummy_request
123        user = request.root['john']
124        request.method = 'POST'
125        request.POST = MultiDict({'submit': True})
126        response = workout_views.add_workout_manually(user, request)
127        assert 'form' in response
128        # All required fields (6) are marked in the form errors
129        assert len(response['form'].form.errors) == 6
130
131    add_workout_params = [
132        # no title, no sport, we generate a title based on when the
133        # workout started
134        ({'title': None, 'sport': None},
135         {'title': 'Morning workout',
136          'distance': 10}),
137        # no title, sport given, we use the sport too in the automatically
138        # generated title
139        ({'title': None, 'sport': 'cycling'},
140         {'title': 'Morning cycling workout',
141          'distance': 10}),
142        # title given, no sport, we use the provided title
143        ({'title': 'Example workout', 'sport': None},
144         {'title': 'Example workout',
145          'distance': 10}),
146        # title given, sport too, we use the provided title
147        ({'title': 'Example workout', 'sport': 'cycling'},
148         {'title': 'Example workout',
149          'distance': 10}),
150    ]
151
152    @pytest.mark.parametrize(('params', 'expected'), add_workout_params)
153    def test_add_workout_manually_post_valid(self, params, expected,
154                                             valid_post_request):
155        """
156        POST request to add a workout manually, providing the needed data
157        """
158        request = valid_post_request
159        if params['title'] is not None:
160            request.POST['title'] = params['title']
161        if params['sport'] is not None:
162            request.POST['sport'] = params['sport']
163        user = request.root['john']
164        assert len(user.workouts()) == 1
165        response = workout_views.add_workout_manually(user, request)
166        assert isinstance(response, HTTPFound)
167        assert response.location.endswith('/2/')
168        assert len(user.workouts()) == 2
169        assert user['2'].title == expected['title']
170        assert isinstance(user['2'].distance, Decimal)
171        assert user['2'].distance == Decimal(expected['distance'])
172
173    def test_add_workout_get(self, dummy_request):
174        """
175        Test the view that renders the "add workout by upload tracking file"
176        form
177        """
178        request = dummy_request
179        user = request.root['john']
180        response = workout_views.add_workout(user, request)
181        assert 'form' in response
182        assert len(response['form'].form.errors) == 0
183        assert isinstance(response['form'].form.schema, UploadedWorkoutSchema)
184
185    def test_add_workout_post_invalid(self, dummy_request):
186        """
187        POST request to add a workout by uploading a tracking file, without
188        providing the required form data.
189        """
190        request = dummy_request
191        user = request.root['john']
192        request.method = 'POST'
193        request.POST = MultiDict({'submit': True})
194        response = workout_views.add_workout(user, request)
195        assert 'form' in response
196        # Only one required field in this case, the tracking file
197        assert len(response['form'].form.errors) == 1
198
199    def test_add_workout_post_invalid_bytes(self, dummy_request):
200        """
201        POST request to add a workout, without uploading a tracking file,
202        which sends an empty bytes object (b'')
203        """
204        request = dummy_request
205        user = request.root['john']
206        request.method = 'POST'
207        request.POST = MultiDict({
208            'tracking_file': b'',
209            'submit': True,
210            })
211        assert len(request.root['john'].workouts()) == 1
212        response = workout_views.add_workout(user, request)
213        assert 'form' in response
214        # Only one required field in this case, the tracking file
215        assert len(response['form'].form.errors) == 1
216
217    @pytest.mark.parametrize('filename', gpx_filenames)
218    def test_add_workout_post_valid(self, filename, dummy_request):
219        """
220        POST request to add a workout, uploading a tracking file
221        """
222        request = dummy_request
223        uploaded_file = self.open_uploaded_file(filename)
224        filestorage = self.create_filestorage(uploaded_file)
225        user = request.root['john']
226        request.method = 'POST'
227        request.POST = MultiDict({
228            'tracking_file': filestorage,
229            'submit': True,
230            })
231        assert len(request.root['john'].workouts()) == 1
232        response = workout_views.add_workout(user, request)
233        assert isinstance(response, HTTPFound)
234        assert response.location.endswith('/2/')
235        assert len(request.root['john'].workouts()) == 2
236        self.close_uploaded_file(uploaded_file)
237
238    def test_edit_workout_get(self, dummy_request):
239        """
240        Test the view that renders the "edit workout" form
241        """
242        request = dummy_request
243        user = request.root['john']
244        workout = user.workouts()[0]
245        response = workout_views.edit_workout(workout, request)
246        assert 'form' in response
247        assert len(response['form'].form.errors) == 0
248        assert isinstance(response['form'].form.schema, ManualWorkoutSchema)
249
250    def test_edit_workout_post_invalid(self, dummy_request):
251        """
252        POST request to edit a workout, without providing the required form
253        data (like removing data from required fields).
254        """
255        request = dummy_request
256        user = request.root['john']
257        workout = user.workouts()[0]
258        request.method = 'POST'
259        request.POST = MultiDict({'submit': True})
260        response = workout_views.edit_workout(workout, request)
261        assert 'form' in response
262        # All required fields (6) are marked in the form errors
263        assert len(response['form'].form.errors) == 6
264
265    def test_edit_workout_post_valid(self, valid_post_request):
266        """
267        POST request to edit a workout, providing the needed data
268        """
269        request = valid_post_request
270        user = request.root['john']
271        workout = user.workouts()[0]
272        assert len(user.workouts()) == 1
273        assert workout.start == datetime(
274            2015, 6, 28, 12, 55, tzinfo=timezone.utc)
275        response = workout_views.edit_workout(workout, request)
276        assert isinstance(response, HTTPFound)
277        assert response.location.endswith('/1/')
278        assert len(user.workouts()) == 1
279        assert user.workouts()[0].start == datetime(
280            2015, 12, 21, 8, 30, tzinfo=timezone.utc)
281
282    def test_update_workout_from_file_get(self, dummy_request):
283        """
284        Test the view that renders the "update workout from file" form
285        """
286        request = dummy_request
287        user = request.root['john']
288        workout = user.workouts()[0]
289        response = workout_views.update_workout_from_file(workout, request)
290        assert 'form' in response
291        assert len(response['form'].form.errors) == 0
292        assert isinstance(response['form'].form.schema, UpdateWorkoutSchema)
293
294    def test_update_workout_from_file_post_invalid(self, dummy_request):
295        """
296        POST request to update a workout by uploading a tracking file, without
297        providing the required form data.
298        """
299        request = dummy_request
300        user = request.root['john']
301        workout = user.workouts()[0]
302        request.method = 'POST'
303        request.POST = MultiDict({'submit': True})
304        response = workout_views.update_workout_from_file(workout, request)
305        assert 'form' in response
306        # Only one required field in this case, the tracking file
307        assert len(response['form'].form.errors) == 1
308
309    def test_update_workout_from_file_post_invalid_bytes(self, dummy_request):
310        """
311        POST request to update a workout, without uploading a tracking file,
312        which sends an empty bytes object (b'')
313        """
314        request = dummy_request
315        user = request.root['john']
316        workout = user.workouts()[0]
317        request.method = 'POST'
318        request.POST = MultiDict({
319            'tracking_file': b'',
320            'submit': True,
321            })
322        response = workout_views.update_workout_from_file(workout, request)
323        assert 'form' in response
324        # Only one required field in this case, the tracking file
325        assert len(response['form'].form.errors) == 1
326
327    @pytest.mark.parametrize('filen', gpx_filenames)
328    def test_update_workout_from_file_post_valid(self, filen, dummy_request):
329        """
330        POST request to update a workout, uploading a tracking file
331        """
332        filename = filen
333        request = dummy_request
334        uploaded_file = self.open_uploaded_file(filename)
335        filestorage = self.create_filestorage(uploaded_file)
336        user = request.root['john']
337        workout = user.workouts()[0]
338        request.method = 'POST'
339        request.POST = MultiDict({
340            'tracking_file': filestorage,
341            'submit': True,
342            })
343        assert len(user.workouts()) == 1
344        response = workout_views.update_workout_from_file(workout, request)
345        assert isinstance(response, HTTPFound)
346        assert response.location.endswith('/1/')
347        assert len(request.root['john'].workouts()) == 1
348        self.close_uploaded_file(uploaded_file)
349
350    def test_delete_workout_get(self, dummy_request):
351        request = dummy_request
352        user = request.root['john']
353        workout = user.workouts()[0]
354        response = workout_views.delete_workout(workout, request)
355        assert response == {}
356
357    def test_delete_workout_post_invalid(self, dummy_request):
358        request = dummy_request
359        user = request.root['john']
360        workout = user.workouts()[0]
361        request.method = 'POST'
362        # invalid, missing confirmation delete hidden value
363        request.POST = MultiDict({'submit': True})
364        response = workout_views.delete_workout(workout, request)
365        # we do reload the page asking for confirmation
366        assert response == {}
367
368    def test_delete_workout_post_valid(self, root):
369        """
370        Valid POST request to delete a workout.
371        Instead of reusing the DummyRequest from the request fixture, we do
372        Mock fully the request here, because we need to use
373        authenticated_userid, which cannot be easily set in the DummyRequest
374        """
375        request = Mock()
376        request.root = root
377        request.method = 'POST'
378        request.resource_url.return_value = '/dashboard/'
379        # invalid, missing confirmation delete hidden value
380        request.POST = MultiDict({'submit': True, 'delete': 'yes'})
381        user = request.root['john']
382        workout = user.workouts()[0]
383        # A real request will have the current logged in user id, which we need
384        # for deleting the workout
385        request.authenticated_userid = 'john'
386        response = workout_views.delete_workout(workout, request)
387        # after a successful delete, we send the user back to his dashboard
388        assert isinstance(response, HTTPFound)
389        assert response.location.endswith('/')
390        assert len(user.workouts()) == 0
391
392    def test_workout_without_gpx(self, dummy_request):
393        """
394        Test the view that renders the workout details page for a workout
395        without tracking data
396        """
397        request = dummy_request
398        user = request.root['john']
399        workout = user.workouts()[0]
400        response = workout_views.workout(workout, request)
401        assert response['start_point'] == {}
402
403    def test_workout_with_gpx(self, dummy_request):
404        """
405        Test the view that renders the workout details page for a workout
406        with a gpx tracking file. We use a gpx from the test fixtures
407        """
408        request = dummy_request
409        # expected values (from the gpx fixture file)
410        expected = {'latitude': 37.108735040304566,
411                    'longitude': 25.472489344630546,
412                    'elevation': None}
413
414        user = request.root['john']
415        workout = user.workouts()[0]
416        # to ensure has_gpx returns true
417        workout.tracking_filetype = 'gpx'
418
419        gpx_file_path = os.path.join(
420            os.path.dirname(os.path.dirname(__file__)),
421            'fixtures/20131013.gpx')
422        with patch.object(workout, 'tracking_file') as tf:
423            with open(gpx_file_path, 'r') as gpx_file:
424                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
425                response = workout_views.workout(workout, request)
426                assert response['start_point'] == expected
427
428    def test_workout_gpx_no_gpx(self, dummy_request):
429        """
430        The view that renders the gpx contents attached to a workout return a
431        404 if the workout has no gpx
432        """
433        request = dummy_request
434        user = request.root['john']
435        workout = user.workouts()[0]
436        response = workout_views.workout_gpx(workout, request)
437        assert isinstance(response, HTTPNotFound)
438
439    def test_workout_gpx(self, dummy_request):
440        """
441        The view that renders the gpx contents attached to a workout returns a
442        response containing the gpx contents, as with the proper content_type
443        and all
444        """
445        request = dummy_request
446        user = request.root['john']
447        workout = user.workouts()[0]
448        # to ensure has_gpx returns true
449        workout.tracking_filetype = 'gpx'
450
451        # part of the expected body, so we can assert later
452        expected_body = b'<gpx version="1.1" creator="OpenWorkouts"'
453
454        gpx_file_path = os.path.join(
455            os.path.dirname(os.path.dirname(__file__)),
456            'fixtures/20131013.gpx')
457        with patch.object(workout, 'tracking_file') as tf:
458            with open(gpx_file_path, 'r') as gpx_file:
459                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
460                response = workout_views.workout_gpx(workout, request)
461                assert response.status_code == 200
462                assert response.content_type == 'application/xml'
463                assert expected_body in response.body
464
465    def test_workout_map_no_gpx(self, dummy_request):
466        request = dummy_request
467        user = request.root['john']
468        workout = user.workouts()[0]
469        response = workout_views.workout_map(workout, request)
470        assert response == {'start_point': {}}
471
472    def test_workout_map(self, dummy_request):
473        request = dummy_request
474        user = request.root['john']
475        workout = user.workouts()[0]
476        # to ensure has_gpx returns true
477        workout.tracking_filetype = 'gpx'
478        gpx_file_path = os.path.join(
479            os.path.dirname(os.path.dirname(__file__)),
480            'fixtures/20131013.gpx')
481        with patch.object(workout, 'tracking_file') as tf:
482            with open(gpx_file_path, 'r') as gpx_file:
483                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
484                response = workout_views.workout_map(workout, request)
485                assert response == {
486                    'start_point': {
487                        'elevation': None,
488                        'latitude': 37.108735040304566,
489                        'longitude': 25.472489344630546
490                    }
491                }
492
493    @patch('ow.views.workout.save_map_screenshot')
494    def test_workout_map_shot_generate_map(self, save_map, dummy_request):
495        """
496        Call the view that returns the url to the screenshot of a workout
497        tracking map, without a map being generated previously, the map is
498        generated using save_map_screenshot
499        """
500        def static_url(url):
501            return url
502        request = dummy_request
503        # mock static url to make testing this a bit easier
504        request.static_url = static_url
505        user = request.root['john']
506        workout = user.workouts()[0]
507        # we mock map_screenshot so the first access is None, triggering the
508        # save_map_screenshot call. The second access returns a string we can
509        # use with static_url for testing purposes
510        type(workout).map_screenshot = PropertyMock(
511            side_effect=[None, 'ow:static/maps/somemap.png'])
512        response = workout_views.workout_map_shot(workout, request)
513        save_map.assert_called_once_with(workout, request)
514        assert isinstance(response, Response)
515        assert response.content_type == 'application/json'
516        # the body is a valid json-encoded stream
517        obj = json.loads(response.body)
518        assert 'ow:static/maps/somemap.png' in obj['url']
519
520    @patch('ow.views.workout.save_map_screenshot')
521    def test_workout_map_shot_existing(self, save_map, dummy_request):
522        """
523        Call the view that returns the url to the screenshot of a workout
524        tracking map, with an existing map already there
525        """
526        def static_url(url):
527            return url
528        request = dummy_request
529        # mock static url to make testing this a bit easier
530        request.static_url = static_url
531        user = request.root['john']
532        workout = user.workouts()[0]
533        type(workout).map_screenshot = PropertyMock(
534            side_effect=['ow:static/maps/somemap.png',
535                         'ow:static/maps/somemap.png'])
536        response = workout_views.workout_map_shot(workout, request)
537        assert not save_map.called
538        assert isinstance(response, Response)
539        assert response.content_type == 'application/json'
540        # the body is a valid json-encoded stream
541        obj = json.loads(response.body)
542        assert 'ow:static/maps/somemap.png' in obj['url']
Note: See TracBrowser for help on using the repository browser.