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
RevLine 
[5ec3a0b]1import os
[cef474f]2import json
[5ec3a0b]3from io import BytesIO
4from datetime import datetime, timedelta, timezone
5from cgi import FieldStorage
[cef474f]6from unittest.mock import Mock, patch, PropertyMock
[e52a502]7from decimal import Decimal
[5ec3a0b]8
9import pytest
10
11from pyramid.testing import DummyRequest
12from pyramid.httpexceptions import HTTPFound, HTTPNotFound
[cef474f]13from pyramid.response import Response
[5ec3a0b]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
[d517001]131    add_workout_params = [
132        # no title, no sport, we generate a title based on when the
133        # workout started
[e52a502]134        ({'title': None, 'sport': None},
135         {'title': 'Morning workout',
136          'distance': 10}),
[d517001]137        # no title, sport given, we use the sport too in the automatically
138        # generated title
[e52a502]139        ({'title': None, 'sport': 'cycling'},
140         {'title': 'Morning cycling workout',
141          'distance': 10}),
[d517001]142        # title given, no sport, we use the provided title
[e52a502]143        ({'title': 'Example workout', 'sport': None},
144         {'title': 'Example workout',
145          'distance': 10}),
[d517001]146        # title given, sport too, we use the provided title
[e52a502]147        ({'title': 'Example workout', 'sport': 'cycling'},
148         {'title': 'Example workout',
149          'distance': 10}),
[d517001]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):
[5ec3a0b]155        """
156        POST request to add a workout manually, providing the needed data
157        """
158        request = valid_post_request
[d517001]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']
[5ec3a0b]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
[e52a502]169        assert user['2'].title == expected['title']
170        assert isinstance(user['2'].distance, Decimal)
171        assert user['2'].distance == Decimal(expected['distance'])
[5ec3a0b]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
[fe6089a]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
[5ec3a0b]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
[fe6089a]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
[5ec3a0b]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
[ceae158]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                }
[cef474f]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.