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

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

(#39) Duplicated workouts, fixed broken tests, added more tests coverage

  • Property mode set to 100644
File size: 23.4 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        assert response['duplicate'] is None
185
186    def test_add_workout_post_invalid(self, dummy_request):
187        """
188        POST request to add a workout by uploading a tracking file, without
189        providing the required form data.
190        """
191        request = dummy_request
192        user = request.root['john']
193        request.method = 'POST'
194        request.POST = MultiDict({'submit': True})
195        response = workout_views.add_workout(user, request)
196        assert 'form' in response
197        # Only one required field in this case, the tracking file
198        assert len(response['form'].form.errors) == 1
199        assert response['duplicate'] is None
200
201    def test_add_workout_post_invalid_bytes(self, dummy_request):
202        """
203        POST request to add a workout, without uploading a tracking file,
204        which sends an empty bytes object (b'')
205        """
206        request = dummy_request
207        user = request.root['john']
208        request.method = 'POST'
209        request.POST = MultiDict({
210            'tracking_file': b'',
211            'submit': True,
212            })
213        assert len(request.root['john'].workouts()) == 1
214        response = workout_views.add_workout(user, request)
215        assert 'form' in response
216        # Only one required field in this case, the tracking file
217        assert len(response['form'].form.errors) == 1
218        assert response['duplicate'] is None
219
220    @pytest.mark.parametrize('filename', gpx_filenames)
221    def test_add_workout_post_valid(self, filename, dummy_request):
222        """
223        POST request to add a workout, uploading a tracking file
224        """
225        request = dummy_request
226        uploaded_file = self.open_uploaded_file(filename)
227        filestorage = self.create_filestorage(uploaded_file)
228        user = request.root['john']
229        request.method = 'POST'
230        request.POST = MultiDict({
231            'tracking_file': filestorage,
232            'submit': True,
233            })
234        assert len(request.root['john'].workouts()) == 1
235        response = workout_views.add_workout(user, request)
236        assert isinstance(response, HTTPFound)
237        assert response.location.endswith('/2/')
238        assert len(request.root['john'].workouts()) == 2
239        self.close_uploaded_file(uploaded_file)
240
241    def test_add_workout_post_duplicate(self, dummy_request):
242        """
243        POST request, first add a workout uploading one of the sample
244        tracking files, then try to upload it again.
245        """
246        # first, upload the workout
247        filename = self.gpx_filenames[0]
248        request = dummy_request
249        uploaded_file = self.open_uploaded_file(filename)
250        filestorage = self.create_filestorage(uploaded_file)
251        user = request.root['john']
252        request.method = 'POST'
253        request.POST = MultiDict({
254            'tracking_file': filestorage,
255            'submit': True,
256            })
257        assert len(request.root['john'].workouts()) == 1
258        response = workout_views.add_workout(user, request)
259        assert isinstance(response, HTTPFound)
260        assert response.location.endswith('/2/')
261        assert len(request.root['john'].workouts()) == 2
262        self.close_uploaded_file(uploaded_file)
263        # now, try to upload it again
264        uploaded_file = self.open_uploaded_file(filename)
265        filestorage = self.create_filestorage(uploaded_file)
266        user = request.root['john']
267        request.method = 'POST'
268        request.POST = MultiDict({
269            'tracking_file': filestorage,
270            'submit': True,
271            })
272        response = workout_views.add_workout(user, request)
273        assert response['duplicate'] == request.root['john']['2']
274        assert len(request.root['john'].workouts()) == 2
275        self.close_uploaded_file(uploaded_file)
276        # finally, override the duplicate prevention code and save it
277        uploaded_file = self.open_uploaded_file(filename)
278        filestorage = self.create_filestorage(uploaded_file)
279        user = request.root['john']
280        request.method = 'POST'
281        request.POST = MultiDict({
282            'tracking_file': filestorage,
283            'allow_duplicates': 'on',
284            'submit': True,
285            })
286        response = workout_views.add_workout(user, request)
287        assert isinstance(response, HTTPFound)
288        assert response.location.endswith('/3/')
289        assert len(request.root['john'].workouts()) == 3
290        self.close_uploaded_file(uploaded_file)
291
292    def test_edit_workout_get(self, dummy_request):
293        """
294        Test the view that renders the "edit workout" form
295        """
296        request = dummy_request
297        user = request.root['john']
298        workout = user.workouts()[0]
299        response = workout_views.edit_workout(workout, request)
300        assert 'form' in response
301        assert len(response['form'].form.errors) == 0
302        assert isinstance(response['form'].form.schema, ManualWorkoutSchema)
303
304    def test_edit_workout_post_invalid(self, dummy_request):
305        """
306        POST request to edit a workout, without providing the required form
307        data (like removing data from required fields).
308        """
309        request = dummy_request
310        user = request.root['john']
311        workout = user.workouts()[0]
312        request.method = 'POST'
313        request.POST = MultiDict({'submit': True})
314        response = workout_views.edit_workout(workout, request)
315        assert 'form' in response
316        # All required fields (6) are marked in the form errors
317        assert len(response['form'].form.errors) == 6
318
319    def test_edit_workout_post_valid(self, valid_post_request):
320        """
321        POST request to edit a workout, providing the needed data
322        """
323        request = valid_post_request
324        user = request.root['john']
325        workout = user.workouts()[0]
326        assert len(user.workouts()) == 1
327        assert workout.start == datetime(
328            2015, 6, 28, 12, 55, tzinfo=timezone.utc)
329        response = workout_views.edit_workout(workout, request)
330        assert isinstance(response, HTTPFound)
331        assert response.location.endswith('/1/')
332        assert len(user.workouts()) == 1
333        assert user.workouts()[0].start == datetime(
334            2015, 12, 21, 8, 30, tzinfo=timezone.utc)
335
336    def test_update_workout_from_file_get(self, dummy_request):
337        """
338        Test the view that renders the "update workout from file" form
339        """
340        request = dummy_request
341        user = request.root['john']
342        workout = user.workouts()[0]
343        response = workout_views.update_workout_from_file(workout, request)
344        assert 'form' in response
345        assert len(response['form'].form.errors) == 0
346        assert isinstance(response['form'].form.schema, UpdateWorkoutSchema)
347
348    def test_update_workout_from_file_post_invalid(self, dummy_request):
349        """
350        POST request to update a workout by uploading a tracking file, without
351        providing the required form data.
352        """
353        request = dummy_request
354        user = request.root['john']
355        workout = user.workouts()[0]
356        request.method = 'POST'
357        request.POST = MultiDict({'submit': True})
358        response = workout_views.update_workout_from_file(workout, request)
359        assert 'form' in response
360        # Only one required field in this case, the tracking file
361        assert len(response['form'].form.errors) == 1
362
363    def test_update_workout_from_file_post_invalid_bytes(self, dummy_request):
364        """
365        POST request to update a workout, without uploading a tracking file,
366        which sends an empty bytes object (b'')
367        """
368        request = dummy_request
369        user = request.root['john']
370        workout = user.workouts()[0]
371        request.method = 'POST'
372        request.POST = MultiDict({
373            'tracking_file': b'',
374            'submit': True,
375            })
376        response = workout_views.update_workout_from_file(workout, request)
377        assert 'form' in response
378        # Only one required field in this case, the tracking file
379        assert len(response['form'].form.errors) == 1
380
381    @pytest.mark.parametrize('filen', gpx_filenames)
382    def test_update_workout_from_file_post_valid(self, filen, dummy_request):
383        """
384        POST request to update a workout, uploading a tracking file
385        """
386        filename = filen
387        request = dummy_request
388        uploaded_file = self.open_uploaded_file(filename)
389        filestorage = self.create_filestorage(uploaded_file)
390        user = request.root['john']
391        workout = user.workouts()[0]
392        request.method = 'POST'
393        request.POST = MultiDict({
394            'tracking_file': filestorage,
395            'submit': True,
396            })
397        assert len(user.workouts()) == 1
398        response = workout_views.update_workout_from_file(workout, request)
399        assert isinstance(response, HTTPFound)
400        assert response.location.endswith('/1/')
401        assert len(request.root['john'].workouts()) == 1
402        self.close_uploaded_file(uploaded_file)
403
404    def test_delete_workout_get(self, dummy_request):
405        request = dummy_request
406        user = request.root['john']
407        workout = user.workouts()[0]
408        response = workout_views.delete_workout(workout, request)
409        assert response == {}
410
411    def test_delete_workout_post_invalid(self, dummy_request):
412        request = dummy_request
413        user = request.root['john']
414        workout = user.workouts()[0]
415        request.method = 'POST'
416        # invalid, missing confirmation delete hidden value
417        request.POST = MultiDict({'submit': True})
418        response = workout_views.delete_workout(workout, request)
419        # we do reload the page asking for confirmation
420        assert response == {}
421
422    def test_delete_workout_post_valid(self, root):
423        """
424        Valid POST request to delete a workout.
425        Instead of reusing the DummyRequest from the request fixture, we do
426        Mock fully the request here, because we need to use
427        authenticated_userid, which cannot be easily set in the DummyRequest
428        """
429        request = Mock()
430        request.root = root
431        request.method = 'POST'
432        request.resource_url.return_value = '/dashboard/'
433        # invalid, missing confirmation delete hidden value
434        request.POST = MultiDict({'submit': True, 'delete': 'yes'})
435        user = request.root['john']
436        workout = user.workouts()[0]
437        # A real request will have the current logged in user id, which we need
438        # for deleting the workout
439        request.authenticated_userid = 'john'
440        response = workout_views.delete_workout(workout, request)
441        # after a successful delete, we send the user back to his dashboard
442        assert isinstance(response, HTTPFound)
443        assert response.location.endswith('/')
444        assert len(user.workouts()) == 0
445
446    def test_workout_without_gpx(self, dummy_request):
447        """
448        Test the view that renders the workout details page for a workout
449        without tracking data
450        """
451        request = dummy_request
452        user = request.root['john']
453        workout = user.workouts()[0]
454        response = workout_views.workout(workout, request)
455        assert response['start_point'] == {}
456
457    def test_workout_with_gpx(self, dummy_request):
458        """
459        Test the view that renders the workout details page for a workout
460        with a gpx tracking file. We use a gpx from the test fixtures
461        """
462        request = dummy_request
463        # expected values (from the gpx fixture file)
464        expected = {'latitude': 37.108735040304566,
465                    'longitude': 25.472489344630546,
466                    'elevation': None}
467
468        user = request.root['john']
469        workout = user.workouts()[0]
470        # to ensure has_gpx returns true
471        workout.tracking_filetype = 'gpx'
472
473        gpx_file_path = os.path.join(
474            os.path.dirname(os.path.dirname(__file__)),
475            'fixtures/20131013.gpx')
476        with patch.object(workout, 'tracking_file') as tf:
477            with open(gpx_file_path, 'r') as gpx_file:
478                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
479                response = workout_views.workout(workout, request)
480                assert response['start_point'] == expected
481
482    def test_workout_gpx_no_gpx(self, dummy_request):
483        """
484        The view that renders the gpx contents attached to a workout return a
485        404 if the workout has no gpx
486        """
487        request = dummy_request
488        user = request.root['john']
489        workout = user.workouts()[0]
490        response = workout_views.workout_gpx(workout, request)
491        assert isinstance(response, HTTPNotFound)
492
493    def test_workout_gpx(self, dummy_request):
494        """
495        The view that renders the gpx contents attached to a workout returns a
496        response containing the gpx contents, as with the proper content_type
497        and all
498        """
499        request = dummy_request
500        user = request.root['john']
501        workout = user.workouts()[0]
502        # to ensure has_gpx returns true
503        workout.tracking_filetype = 'gpx'
504
505        # part of the expected body, so we can assert later
506        expected_body = b'<gpx version="1.1" creator="OpenWorkouts"'
507
508        gpx_file_path = os.path.join(
509            os.path.dirname(os.path.dirname(__file__)),
510            'fixtures/20131013.gpx')
511        with patch.object(workout, 'tracking_file') as tf:
512            with open(gpx_file_path, 'r') as gpx_file:
513                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
514                response = workout_views.workout_gpx(workout, request)
515                assert response.status_code == 200
516                assert response.content_type == 'application/xml'
517                assert expected_body in response.body
518
519    def test_workout_map_no_gpx(self, dummy_request):
520        request = dummy_request
521        user = request.root['john']
522        workout = user.workouts()[0]
523        response = workout_views.workout_map(workout, request)
524        assert response == {'start_point': {}}
525
526    def test_workout_map(self, dummy_request):
527        request = dummy_request
528        user = request.root['john']
529        workout = user.workouts()[0]
530        # to ensure has_gpx returns true
531        workout.tracking_filetype = 'gpx'
532        gpx_file_path = os.path.join(
533            os.path.dirname(os.path.dirname(__file__)),
534            'fixtures/20131013.gpx')
535        with patch.object(workout, 'tracking_file') as tf:
536            with open(gpx_file_path, 'r') as gpx_file:
537                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
538                response = workout_views.workout_map(workout, request)
539                assert response == {
540                    'start_point': {
541                        'elevation': None,
542                        'latitude': 37.108735040304566,
543                        'longitude': 25.472489344630546
544                    }
545                }
546
547    @patch('ow.views.workout.save_map_screenshot')
548    def test_workout_map_shot_generate_map(self, save_map, dummy_request):
549        """
550        Call the view that returns the url to the screenshot of a workout
551        tracking map, without a map being generated previously, the map is
552        generated using save_map_screenshot
553        """
554        def static_url(url):
555            return url
556        request = dummy_request
557        # mock static url to make testing this a bit easier
558        request.static_url = static_url
559        user = request.root['john']
560        workout = user.workouts()[0]
561        # we mock map_screenshot so the first access is None, triggering the
562        # save_map_screenshot call. The second access returns a string we can
563        # use with static_url for testing purposes
564        type(workout).map_screenshot = PropertyMock(
565            side_effect=[None, 'ow:static/maps/somemap.png'])
566        response = workout_views.workout_map_shot(workout, request)
567        save_map.assert_called_once_with(workout, request)
568        assert isinstance(response, Response)
569        assert response.content_type == 'application/json'
570        # the body is a valid json-encoded stream
571        obj = json.loads(response.body)
572        assert 'ow:static/maps/somemap.png' in obj['url']
573
574    @patch('ow.views.workout.save_map_screenshot')
575    def test_workout_map_shot_existing(self, save_map, dummy_request):
576        """
577        Call the view that returns the url to the screenshot of a workout
578        tracking map, with an existing map already there
579        """
580        def static_url(url):
581            return url
582        request = dummy_request
583        # mock static url to make testing this a bit easier
584        request.static_url = static_url
585        user = request.root['john']
586        workout = user.workouts()[0]
587        type(workout).map_screenshot = PropertyMock(
588            side_effect=['ow:static/maps/somemap.png',
589                         'ow:static/maps/somemap.png'])
590        response = workout_views.workout_map_shot(workout, request)
591        assert not save_map.called
592        assert isinstance(response, Response)
593        assert response.content_type == 'application/json'
594        # the body is a valid json-encoded stream
595        obj = json.loads(response.body)
596        assert 'ow:static/maps/somemap.png' in obj['url']
Note: See TracBrowser for help on using the repository browser.