Add tests for statistical database REST API

Issue: HIC-112
Change-Id: I5d6da6f28f1be7e74fd39af08f79d5cb92d0e6c5
diff --git a/crashreport_stats/tests.py b/crashreport_stats/tests.py
index 7ce503c..a204d42 100644
--- a/crashreport_stats/tests.py
+++ b/crashreport_stats/tests.py
@@ -1,3 +1,440 @@
-from django.test import TestCase
+"""Test crashreport_stats models and the 'stats' command."""
+from datetime import datetime, date
+import pytz
 
-# Create your tests here.
+from django.urls import reverse
+from django.utils.http import urlencode
+
+from rest_framework import status
+from rest_framework.test import APITestCase, APIClient
+
+from crashreport_stats.models import (
+    Version, VersionDaily, RadioVersion, RadioVersionDaily
+)
+
+from crashreports.models import User, Device
+
+
+class Dummy():
+    """Class for creating dummy instances for testing."""
+
+    # Valid unique entries
+    BUILD_FINGERPRINTS = [(
+        'Fairphone/FP2/FP2:5.1/FP2/r4275.1_FP2_gms76_1.13.0:user/release-keys'
+    ), (
+        'Fairphone/FP2/FP2:5.1.1/FP2-gms75.1.13.0/FP2-gms75.1.13.0'
+        ':user/release-keys'
+    ), (
+        'Fairphone/FP2/FP2:6.0.1/FP2-gms-18.04.1/FP2-gms-18.04.1'
+        ':user/release-keys'
+    ), (
+        'Fairphone/FP2/FP2:7.1.2/18.07.2/gms-7480c31d'
+        ':user/release-keys'
+    )]
+    RADIO_VERSIONS = ['4437.1-FP2-0-07', '4437.1-FP2-0-08',
+                      '4437.1-FP2-0-09', '4437.1-FP2-0-10']
+
+    DATES = [date(2018, 3, 19), date(2018, 3, 26), date(2018, 5, 1)]
+
+    DEFAULT_DUMMY_VERSION_VALUES = {
+        'build_fingerprint': BUILD_FINGERPRINTS[0],
+        'first_seen_on': DATES[1],
+        'released_on': DATES[0]
+    }
+
+    DEFAULT_DUMMY_VERSION_DAILY_VALUES = {
+        'date': DATES[1]
+    }
+
+    DEFAULT_DUMMY_RADIO_VERSION_VALUES = {
+        'radio_version': RADIO_VERSIONS[0],
+        'first_seen_on': DATES[1],
+        'released_on': DATES[0]
+    }
+
+    DEFAULT_DUMMY_RADIO_VERSION_DAILY_VALUES = {
+        'date': DATES[1]
+    }
+
+    DEFAULT_DUMMY_DEVICE_VALUES = {
+        'board_date': datetime(2015, 12, 15, 1, 23, 45, tzinfo=pytz.utc),
+        'chipset': 'Qualcomm MSM8974PRO-AA',
+        'token': '64111c62d521fb4724454ca6dea27e18f93ef56e'
+    }
+
+    DEFAULT_DUMMY_USER_VALUES = {
+        'username': 'testuser'
+    }
+
+    @staticmethod
+    def update_copy(original, update):
+        """Merge fields of update into a copy of original."""
+        data = original.copy()
+        data.update(update)
+        return data
+
+    @staticmethod
+    def create_dummy_user(**kwargs):
+        """Create a dummy user instance.
+
+        The dummy instance is created and saved to the database.
+        Args:
+            **kwargs:
+                Optional arguments to extend/overwrite the default values.
+
+        Returns: The created user instance.
+
+        """
+        entity = User(**Dummy.update_copy(
+            Dummy.DEFAULT_DUMMY_USER_VALUES, kwargs))
+        entity.save()
+        return entity
+
+    @staticmethod
+    def create_dummy_device(user, **kwargs):
+        """Create a dummy device instance.
+
+        The dummy instance is created and saved to the database.
+        Args:
+            user: The user instance that the device should relate to
+            **kwargs:
+                Optional arguments to extend/overwrite the default values.
+
+        Returns: The created device instance.
+
+        """
+        entity = Device(user=user, **Dummy.update_copy(
+            Dummy.DEFAULT_DUMMY_DEVICE_VALUES, kwargs))
+        entity.save()
+        return entity
+
+    @staticmethod
+    def create_dummy_version(**kwargs):
+        """Create a dummy version instance.
+
+        The dummy instance is created and saved to the database.
+        Args:
+            **kwargs:
+                Optional arguments to extend/overwrite the default values.
+
+        Returns: The created version instance.
+
+        """
+        entity = Version(**Dummy.update_copy(
+            Dummy.DEFAULT_DUMMY_VERSION_VALUES, kwargs))
+        entity.save()
+        return entity
+
+    @staticmethod
+    def create_dummy_radio_version(**kwargs):
+        """Create a dummy radio version instance.
+
+        The dummy instance is created and saved to the database.
+        Args:
+            **kwargs:
+                Optional arguments to extend/overwrite the default values.
+
+        Returns: The created radio version instance.
+
+        """
+        entity = RadioVersion(**Dummy.update_copy(
+            Dummy.DEFAULT_DUMMY_RADIO_VERSION_VALUES, kwargs))
+        entity.save()
+        return entity
+
+    @staticmethod
+    def create_dummy_daily_version(version, **kwargs):
+        """Create a dummy daily version instance.
+
+        The dummy instance is created and saved to the database.
+        Args:
+            **kwargs:
+                Optional arguments to extend/overwrite the default values.
+
+        Returns: The created daily version instance.
+
+        """
+        entity = VersionDaily(version=version, **Dummy.update_copy(
+            Dummy.DEFAULT_DUMMY_VERSION_DAILY_VALUES, kwargs))
+        entity.save()
+        return entity
+
+    @staticmethod
+    def create_dummy_daily_radio_version(version, **kwargs):
+        """Create a dummy daily radio version instance.
+
+        The dummy instance is created and saved to the database.
+        Args:
+            **kwargs:
+                Optional arguments to extend/overwrite the default values.
+
+        Returns: The created daily radio version instance.
+
+        """
+        entity = RadioVersionDaily(version=version, **Dummy.update_copy(
+            Dummy.DEFAULT_DUMMY_RADIO_VERSION_DAILY_VALUES, kwargs))
+        entity.save()
+        return entity
+
+
+class _VersionTestCase(APITestCase):
+    """Abstract class for version-related test cases to inherit from."""
+
+    # The attribute name characterising the unicity of a stats entry (the
+    # named identifier)
+    unique_entry_name = 'build_fingerprint'
+    # The collection of unique entries to post
+    unique_entries = Dummy.BUILD_FINGERPRINTS
+    # The URL to retrieve the stats entries from
+    endpoint_url = reverse('hiccup_stats_api_v1_versions')
+
+    @classmethod
+    def setUpTestData(cls):  # noqa: N802
+        """Create an admin user for accessing the API.
+
+        The APIClient that can be used to make authenticated requests to the
+        server is stored in self.admin.
+        """
+        admin_user = User.objects.create_superuser(
+            'somebody', 'somebody@example.com', 'thepassword')
+        cls.admin = APIClient()
+        cls.admin.force_authenticate(admin_user)
+
+    @staticmethod
+    def _create_dummy_version(**kwargs):
+        return Dummy.create_dummy_version(**kwargs)
+
+    def _get_with_params(self, url, params):
+        return self.admin.get('{}?{}'.format(url, urlencode(params)))
+
+    def _assert_result_length_is(self, response, count):
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertIn('results', response.data)
+        self.assertIn('count', response.data)
+        self.assertEqual(response.data['count'], count)
+        self.assertEqual(len(response.data['results']), count)
+
+    def _assert_device_owner_has_no_get_access(self, entries_url):
+        # Create a user and device
+        user = Dummy.create_dummy_user()
+        device = Dummy.create_dummy_device(user=user)
+
+        # Create authenticated client
+        user = APIClient()
+        user.credentials(HTTP_AUTHORIZATION='Token ' + device.token)
+
+        # Try getting entries using the client
+        response = user.get(entries_url)
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def _assert_filter_result_matches(self, filter_params, expected_result):
+        # List entities with filter
+        response = self._get_with_params(self.endpoint_url, filter_params)
+
+        # Expect only the single matching result to be returned
+        self._assert_result_length_is(response, 1)
+        self.assertEqual(response.data['results'][0][self.unique_entry_name],
+                         getattr(expected_result, self.unique_entry_name))
+
+
+class VersionTestCase(_VersionTestCase):
+    """Test the Version and REST endpoint."""
+
+    def _create_version_entities(self):
+        versions = [
+            self._create_dummy_version(
+                **{self.unique_entry_name: unique_entry}
+            )
+            for unique_entry in self.unique_entries
+        ]
+        return versions
+
+    def test_list_versions_without_authentication(self):
+        """Test listing of versions without authentication."""
+        response = self.client.get(self.endpoint_url)
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def test_list_versions_as_device_owner(self):
+        """Test listing of versions as device owner."""
+        self._assert_device_owner_has_no_get_access(self.endpoint_url)
+
+    def test_list_versions_empty_database(self):
+        """Test listing of versions on an empty database."""
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, 0)
+
+    def test_list_versions(self):
+        """Test listing versions."""
+        versions = self._create_version_entities()
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, len(versions))
+
+    def test_filter_versions_by_unique_entry_name(self):
+        """Test filtering versions by their unique entry name."""
+        versions = self._create_version_entities()
+        response = self.admin.get(self.endpoint_url)
+
+        # Listing all entities should return the correct result length
+        self._assert_result_length_is(response, len(versions))
+
+        # List entities with filter
+        filter_params = {
+            self.unique_entry_name: getattr(versions[0],
+                                            self.unique_entry_name)
+        }
+        self._assert_filter_result_matches(filter_params,
+                                           expected_result=versions[0])
+
+    def test_filter_versions_by_release_type(self):
+        """Test filtering versions by release type."""
+        # Create versions for all combinations of release types
+        versions = []
+        i = 0
+        for is_official_release in True, False:
+            for is_beta_release in True, False:
+                versions.append(self._create_dummy_version(**{
+                    'is_official_release': is_official_release,
+                    'is_beta_release': is_beta_release,
+                    self.unique_entry_name: self.unique_entries[i]
+                }))
+                i += 1
+
+        # # Listing all entities should return the correct result length
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, len(versions))
+
+        # List each of the entities with the matching filter params
+        for version in versions:
+            filter_params = {
+                'is_official_release': version.is_official_release,
+                'is_beta_release': version.is_beta_release
+            }
+            self._assert_filter_result_matches(filter_params,
+                                               expected_result=version)
+
+    def test_filter_versions_by_first_seen_date(self):
+        """Test filtering versions by first seen date."""
+        versions = self._create_version_entities()
+
+        # Set the first seen date of an entity
+        versions[0].first_seen_on = Dummy.DATES[2]
+        versions[0].save()
+
+        # Listing all entities should return the correct result length
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, len(versions))
+
+        # Expect the single matching result to be returned
+        filter_params = {'first_seen_after': Dummy.DATES[2]}
+        self._assert_filter_result_matches(filter_params,
+                                           expected_result=versions[0])
+
+
+# pylint: disable=too-many-ancestors
+class RadioVersionTestCase(VersionTestCase):
+    """Test the RadioVersion REST endpoint."""
+
+    unique_entry_name = 'radio_version'
+    unique_entries = Dummy.RADIO_VERSIONS
+    endpoint_url = reverse('hiccup_stats_api_v1_radio_versions')
+
+    @staticmethod
+    def _create_dummy_version(**kwargs):
+        return Dummy.create_dummy_radio_version(**kwargs)
+
+
+class VersionDailyTestCase(_VersionTestCase):
+    """Test the VersionDaily REST endpoint."""
+
+    endpoint_url = reverse('hiccup_stats_api_v1_version_daily')
+
+    @staticmethod
+    def _create_dummy_daily_version(version, **kwargs):
+        return Dummy.create_dummy_daily_version(version, **kwargs)
+
+    def _create_version_entities(self):
+        versions = [
+            self._create_dummy_version(
+                **{self.unique_entry_name: unique_entry}
+            )
+            for unique_entry in self.unique_entries
+        ]
+        versions_daily = [
+            self._create_dummy_daily_version(version=version)
+            for version in versions
+        ]
+        return versions_daily
+
+    def test_list_daily_versions_without_authentication(self):
+        """Test listing of daily versions without authentication."""
+        response = self.client.get(self.endpoint_url)
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    def test_list_daily_versions_as_device_owner(self):
+        """Test listing of daily versions as device owner."""
+        self._assert_device_owner_has_no_get_access(self.endpoint_url)
+
+    def test_list_daily_versions_empty_database(self):
+        """Test listing of daily versions on an empty database."""
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, 0)
+
+    def test_list_daily_versions(self):
+        """Test listing daily versions."""
+        versions_daily = self._create_version_entities()
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, len(versions_daily))
+
+    def test_filter_daily_versions_by_version(self):
+        """Test filtering versions by the version they relate to."""
+        # Create VersionDaily entities
+        versions = self._create_version_entities()
+
+        # Listing all entities should return the correct result length
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, len(versions))
+
+        # List entities with filter
+        param_name = 'version__' + self.unique_entry_name
+        filter_params = {
+            param_name: getattr(versions[0].version, self.unique_entry_name)
+        }
+        self._assert_filter_result_matches(filter_params,
+                                           expected_result=versions[0].version)
+
+    def test_filter_daily_versions_by_date(self):
+        """Test filtering daily versions by date."""
+        # Create Version and VersionDaily entities
+        versions = self._create_version_entities()
+
+        # Update the date
+        versions[0].date = Dummy.DATES[2]
+        versions[0].save()
+
+        # Listing all entities should return the correct result length
+        response = self.admin.get(self.endpoint_url)
+        self._assert_result_length_is(response, len(versions))
+
+        # Expect the single matching result to be returned
+        filter_params = {'date': versions[0].date}
+        self._assert_filter_result_matches(filter_params,
+                                           expected_result=versions[0].version)
+
+
+class RadioVersionDailyTestCase(VersionDailyTestCase):
+    """Test the RadioVersionDaily REST endpoint."""
+
+    unique_entry_name = 'radio_version'
+    unique_entries = Dummy.RADIO_VERSIONS
+    endpoint_url = reverse('hiccup_stats_api_v1_radio_version_daily')
+
+    @staticmethod
+    def _create_dummy_version(**kwargs):
+        entity = RadioVersion(**Dummy.update_copy(
+            Dummy.DEFAULT_DUMMY_RADIO_VERSION_VALUES, kwargs))
+        entity.save()
+        return entity
+
+    @staticmethod
+    def _create_dummy_daily_version(version, **kwargs):
+        return Dummy.create_dummy_daily_radio_version(version, **kwargs)