diff options
| -rw-r--r-- | pysite/tables.py | 8 | ||||
| -rw-r--r-- | pysite/views/api/bot/bigbrother.py | 118 | ||||
| -rw-r--r-- | tests/test_api_bot_bigbrother.py | 152 | 
3 files changed, 278 insertions, 0 deletions
| diff --git a/pysite/tables.py b/pysite/tables.py index 617a20f0..8f849664 100644 --- a/pysite/tables.py +++ b/pysite/tables.py @@ -257,5 +257,13 @@ TABLES = {              "key",  # str              "value"  # any          ]) +    ), + +    "watched_users": Table(  # Users being monitored by the bot's BigBrother cog +        primary_key="user_id", +        keys=sorted([ +            "user_id", +            "channel_id" +        ])      )  } diff --git a/pysite/views/api/bot/bigbrother.py b/pysite/views/api/bot/bigbrother.py new file mode 100644 index 00000000..89697811 --- /dev/null +++ b/pysite/views/api/bot/bigbrother.py @@ -0,0 +1,118 @@ +import json + +from flask import jsonify +from schema import And, Optional, Schema + +from pysite.base_route import APIView +from pysite.constants import ValidationTypes +from pysite.decorators import api_key, api_params +from pysite.mixins import DBMixin + + +GET_SCHEMA = Schema({ +    # This is passed as a GET parameter, so it has to be a string +    Optional('user_id'): And(str, str.isnumeric, error="`user_id` must be a numeric string") +}) + +POST_SCHEMA = Schema({ +    'user_id': And(str, str.isnumeric, error="`user_id` must be a numeric string"), +    'channel_id': And(str, str.isnumeric, error="`channel_id` must be a numeric string") +}) + +DELETE_SCHEMA = Schema({ +    'user_id': And(str, str.isnumeric, error="`user_id` must be a numeric string") +}) + + +NOT_A_NUMBER_JSON = json.dumps({ +    'error_message': "The given `user_id` parameter is not a valid number" +}) +NOT_FOUND_JSON = json.dumps({ +    'error_message': "No entry for the requested user ID could be found." +}) + + +class BigBrotherView(APIView, DBMixin): +    path = '/bot/bigbrother' +    name = 'bot.bigbrother' +    table_name = 'watched_users' + +    @api_key +    @api_params(schema=GET_SCHEMA, validation_type=ValidationTypes.params) +    def get(self, params): +        """ +        Without query parameters, returns a list of all monitored users. +        A parameter `user_id` can be specified to return a single entry, +        or a dictionary with the string field 'error_message' that tells why it failed. + +        If the returned status is 200, has got either a list of entries +        or a single object (see above). + +        If the returned status is 400, the `user_id` parameter was incorrectly specified. +        If the returned status is 404, the given `user_id` could not be found. +        See the 'error_message' field in the JSON response for more information. + +        The user ID must be provided as query parameter. +        API key must be provided as header. +        """ + +        user_id = params.get('user_id') +        if user_id is not None: +            data = self.db.get(self.table_name, user_id) +            if data is None: +                return NOT_FOUND_JSON, 404 +            return jsonify(data) + +        else: +            data = self.db.pluck(self.table_name, ('user_id', 'channel_id')) or [] +            return jsonify(data) + +    @api_key +    @api_params(schema=POST_SCHEMA, validation_type=ValidationTypes.json) +    def post(self, data): +        """ +        Adds a new entry to the database. +        Entries take the following form: +        { +            "user_id": ...,  # The user ID of the user being monitored, as a string. +            "channel_id": ...  # The channel ID that the user's messages will be relayed to, as a string. +        } + +        If an entry for the given `user_id` already exists, it will be updated with the new channel ID. + +        Returns 204 (ok, empty response) on success. + +        Data must be provided as JSON. +        API key must be provided as header. +        """ + +        self.db.insert( +            self.table_name, +            { +                'user_id': data['user_id'], +                'channel_id': data['channel_id'] +            }, +            conflict='update' +        ) + +        return '', 204 + +    @api_key +    @api_params(schema=DELETE_SCHEMA, validation_type=ValidationTypes.params) +    def delete(self, params): +        """ +        Removes an entry for the given `user_id`. + +        Returns 204 (ok, empty response) on success. +        Returns 400 if the given `user_id` is invalid. + +        The user ID must be provided as query parameter. +        API key must be provided as header. +        """ + +        self.db.delete( +            self.table_name, +            params['user_id'] +        ) + +        return '', 204 diff --git a/tests/test_api_bot_bigbrother.py b/tests/test_api_bot_bigbrother.py new file mode 100644 index 00000000..b1060e72 --- /dev/null +++ b/tests/test_api_bot_bigbrother.py @@ -0,0 +1,152 @@ +import json + +from tests import SiteTest, app + + +class EmptyDatabaseEndpointTests(SiteTest): +    def test_api_docs_get_all(self): +        response = self.client.get( +            '/bot/bigbrother', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'] +        ) +        self.assert200(response) +        self.assertIsInstance(response.json, list) + +    def test_fetching_single_entry_returns_404(self): +        response = self.client.get( +            '/bot/bigbrother?user_id=01932', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'] +        ) +        self.assert404(response) +        self.assertIsInstance(response.json['error_message'], str) + + +class AddingAnEntryEndpointTests(SiteTest): +    GOOD_DATA = { +        'user_id': '42', +        'channel_id': '55' +    } +    GOOD_DATA_JSON = json.dumps(GOOD_DATA) + +    def setUp(self): +        response = self.client.post( +            '/bot/bigbrother', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'], +            data=self.GOOD_DATA_JSON +        ) +        self.assertEqual(response.status_code, 204) + +    def test_entry_is_in_all_entries(self): +        response = self.client.get( +            '/bot/bigbrother', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'] +        ) +        self.assert200(response) +        self.assertIn(self.GOOD_DATA, response.json) + +    def test_can_fetch_entry_with_param_lookup(self): +        response = self.client.get( +            f'/bot/bigbrother?user_id={self.GOOD_DATA["user_id"]}', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'] +        ) +        self.assert200(response) +        self.assertEqual(response.json, self.GOOD_DATA) + + +class UpdatingAnEntryEndpointTests(SiteTest): +    ORIGINAL_DATA = { +        'user_id': '300', +        'channel_id': '400' +    } +    ORIGINAL_DATA_JSON = json.dumps(ORIGINAL_DATA) +    UPDATED_DATA = { +        'user_id': '300', +        'channel_id': '500' +    } +    UPDATED_DATA_JSON = json.dumps(UPDATED_DATA) + +    def setUp(self): +        response = self.client.post( +            '/bot/bigbrother', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'], +            data=self.ORIGINAL_DATA_JSON +        ) +        self.assertEqual(response.status_code, 204) + +    def test_can_update_data(self): +        response = self.client.post( +            '/bot/bigbrother', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'], +            data=self.UPDATED_DATA_JSON +        ) +        self.assertEqual(response.status_code, 204) + + +class DeletingAnEntryEndpointTests(SiteTest): +    SAMPLE_DATA = { +        'user_id': '101', +        'channel_id': '202' +    } +    SAMPLE_DATA_JSON = json.dumps(SAMPLE_DATA) + +    def setUp(self): +        response = self.client.post( +            '/bot/bigbrother', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'], +            data=self.SAMPLE_DATA_JSON +        ) +        self.assertEqual(response.status_code, 204) + +    def test_delete_entry_returns_204(self): +        response = self.client.delete( +            f'/bot/bigbrother?user_id={self.SAMPLE_DATA["user_id"]}', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'] +        ) +        self.assertEqual(response.status_code, 204) + + +class SchemaValidationTests(SiteTest): +    def test_get_with_invalid_user_id_param_returns_400(self): +        response = self.client.get( +            '/bot/bigbrother?user_id=lemon-is-not-a-number', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'] +        ) + +        self.assert400(response) +        self.assertIsInstance(response.json['error_message'], str) + +    def test_post_with_invalid_data_returns_400(self): +        bad_data_json = json.dumps({ +            'user_id': "I'M A NUMBER I SWEAR", +            'channel_id': '42' +        }) + +        response = self.client.post( +            '/bot/bigbrother', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'], +            data=bad_data_json +        ) + +        self.assert400(response) +        self.assertIsInstance(response.json['error_message'], str) + +    def test_delete_with_invalid_user_id_param_returns_400(self): +        response = self.client.delete( +            '/bot/bigbrother?user_id=totally-a-valid-number', +            app.config['API_SUBDOMAIN'], +            headers=app.config['TEST_HEADER'] +        ) + +        self.assert400(response) +        self.assertIsInstance(response.json['error_message'], str) | 
