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
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)
[0dedfbe]184        assert response['duplicate'] is None
[5ec3a0b]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
[0dedfbe]199        assert response['duplicate'] is None
[5ec3a0b]200
[fe6089a]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
[0dedfbe]218        assert response['duplicate'] is None
[fe6089a]219
[5ec3a0b]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
[0dedfbe]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
[5ec3a0b]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
[fe6089a]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
[5ec3a0b]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
[ceae158]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                }
[cef474f]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.