diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..70635eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +include/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +pip-selfcheck.json +bin/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ +*_venv/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# idea +.idea/ + +# miscellaneous files +log/ +.DS_Store +baseline_coverage/ +.docker_config/ +reports/ + +# intellij +*.iml + +# vs code +*.vscode + +# Pact files +.pacts/ + +# SonarQube +.scannerwork/ diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..3df36c8 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,34 @@ + + +# Shorter URL Application +This application contains the APIs for translate long URLs in short URLs. + +## Getting Started +#### First make sure you run this code in a virtual environment: + +* pip (or conda) package manager are required +`python3 -m venv shorter_venv` +`source shorter_venv/bin/activate` + + +#### Now to get the app running: + +1. Create a build `./build.sh` +2. Start the local dev server `./server.sh` +3. Check that the app is running: http://localhost:5000/shorter/healthcheck/ +4. For Unit test execute `pytest` +5. For Feature test execute `behave` + + +## Database + +### Applying Changes +Alembic is in implementation process + +###TODO: + +* Complete UT for all APIs +* Change SQL DB for some NON-SQL DB +* Dockerize +* Add log +* Test date object properly diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2bafe65 --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +echo "Installing Requirements" +pip install -r requirements.txt + +echo "Auto-formatting code" +#black . + +echo "Running Unit Tests" +if ! pytest; then + echo "One or more unit tests failed" + exit 1 +fi + +echo "Running System Tests" +if ! behave; then + echo "One or more system tests failed" + exit 1 +fi +echo "Build Done!" diff --git a/features/__init__.py b/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/api.feature b/features/api.feature new file mode 100644 index 0000000..9ef4fad --- /dev/null +++ b/features/api.feature @@ -0,0 +1,92 @@ +Feature: Test Shorter API + + Scenario: Healthcheck + When I test healthcheck + Then I get status code 200 + And the value of Service is OK + + Scenario: Get a short URL list + When I get a valid short URL with a valid code + Then I get status code 200 + + + Scenario: Create a short URL + When I post a valid short URL with a valid code + Then I get status code 201 + And the code is correct + + + Scenario: Create a short URL without code + When I post a valid short URL without code + Then I get status code 201 + And the code is correct + + + Scenario: Invalid URL + When I post an invalid URL + Then I get status code 400 + And the value of Error is ERROR_INVALID_URL + + + Scenario: Missing URL + When I post a missing URL + Then I get status code 400 + And the value of Error is ERROR_URL_IS_REQUIRED + + + Scenario: Invalid code + When I post an invalid code + Then I get status code 400 + And the value of Error is ERROR_INVALID_CODE + + + Scenario: Duplicated code + When I post a valid short URL with a valid code + Then I get status code 201 + And the code is correct + When I post a valid short URL with the same valid code + Then I get status code 409 + And the value of Error is ERROR_DUPLICATED_CODE + + + Scenario: Get code + When I post a valid short URL with a valid code and URL http://url.com + Then I get status code 201 + And the code is correct + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + + Scenario: Get invalid code + When I get an invalid code + Then I get status code 404 + And the value of Error is ERROR_CODE_NOT_FOUND + + + Scenario: Get code stats + When I post a valid short URL with a valid code and URL http://url.com + Then I get status code 201 + And the code is correct + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + When I get the same valid code + Then I get status code 302 + And the value for location header is http://url.com + + When I get code stats + Then the value of created_at is a_valid_date + Then the value of last_usage is a_valid_date + Then the value of usage_count is 3 + + + Scenario: Get an invalid code for stats + When I get invalid code stats + Then I get status code 404 + And the value of Error is ERROR_CODE_NOT_FOUND diff --git a/features/environment.py b/features/environment.py new file mode 100644 index 0000000..40e7997 --- /dev/null +++ b/features/environment.py @@ -0,0 +1,25 @@ +import os +import tempfile +from behave import fixture, use_fixture + +from shorter_app.app import create_app +from shorter_app.database import init_db +from shorter_app.config import TestConfig + + +@fixture +def shorter_client(context, *args, **kwargs): + app = create_app(TestConfig) + context.db, app.config["DATABASE"] = tempfile.mkstemp() + app.testing = True + context.client = app.test_client() + with app.app_context(): + init_db() + yield context.client + # -- CLEANUP: + os.close(context.db) + os.unlink(app.config["DATABASE"]) + + +def before_feature(context, feature): + use_fixture(shorter_client, context) diff --git a/features/steps/__init__.py b/features/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/steps/api.py b/features/steps/api.py new file mode 100644 index 0000000..ce842c4 --- /dev/null +++ b/features/steps/api.py @@ -0,0 +1,139 @@ +import json +from behave import when, then +from shorter_app.apis.shorter_api import generate_shortcode +from shorter_app.validator import ( + ERROR_INVALID_CODE, + ERROR_DUPLICATED_CODE, + ERROR_INVALID_URL, + ERROR_CODE_NOT_FOUND, + ERROR_URL_IS_REQUIRED, +) + + +@when("I test healthcheck") +def step_impl(context): + response = context.client.get("/shorter/healthcheck/") + context.response = response + + +@then("I get status code {status_code}") +def step_impl(context, status_code): + assert int(status_code) == context.response.status_code + + +@then("the value for location header is {url}") +def test_get_code(context, url): + assert context.response.headers.get("location") == url + + +@then("the value of {attribute} is {value}") +def step_impl(context, attribute, value): + if attribute in ["created_at", "last_usage"]: + assert json.loads(context.response.data).get(attribute) + elif attribute == "usage_count": + assert json.loads(context.response.data).get(attribute) == value + else: + if value == "ERROR_INVALID_CODE": + value = ERROR_INVALID_CODE + if value == "ERROR_DUPLICATED_CODE": + value = ERROR_DUPLICATED_CODE + if value == "ERROR_INVALID_URL": + value = ERROR_INVALID_URL + if value == "ERROR_CODE_NOT_FOUND": + value = ERROR_CODE_NOT_FOUND + if value == "ERROR_URL_IS_REQUIRED": + value = ERROR_URL_IS_REQUIRED + + assert json.loads(context.response.data).get(attribute) == value + + +@when("I post a valid short URL with a valid code") +def test_post_ok(context): + context.code = generate_shortcode() + response = context.client.post( + "/shorter/urls/", json={"url": "http://url.com", "code": context.code} + ) + context.response = response + + +@when("I post a valid short URL with a valid code and URL {url}") +def test_post_ok(context, url): + context.code = generate_shortcode() + response = context.client.post( + "/shorter/urls/", json={"url": url, "code": context.code} + ) + context.response = response + + +@then("the code is correct") +def step_impl(context): + code = json.loads(context.response.data).get("code") + assert code == context.code + + +@when("I post a valid short URL without code") +def test_post_ok_without_code(context): + response = context.client.post("/shorter/urls/", json={"url": "http://url.com"}) + context.response = response + context.code = json.loads(response.data).get("code") + + +@when("I post an invalid URL") +def test_post_invalid_url(context): + response = context.client.post("/shorter/urls/", json={"url": "XXX"}) + context.response = response + + +@when("I post a missing URL") +def test_post_invalid_url(context): + response = context.client.post("/shorter/urls/", json={"invalid_field": "XXX"}) + context.response = response + + +@when("I post an invalid code") +def test_post_invalid_shortcode(context): + response = context.client.post( + "/shorter/urls/", json={"url": "http://url.com", "code": "$%$^$%&"} + ) + context.response = response + + +@when("I post a valid short URL with the same valid code") +def test_post_duplicated_shortcode(context): + code = context.code + response = context.client.post( + "/shorter/urls/", json={"url": "http://url.com", "code": code} + ) + context.response = response + + +@when("I get the same valid code") +def test_get_code(context): + code = context.code + response = context.client.get(f"/shorter/urls/{code}/") + context.response = response + + +@when("I get an invalid code") +def test_get_url_invalid_code(context): + response = context.client.get("/shorter/urls/XXXX/") + context.response = response + + +@when("I get code stats") +def test_get_code_stats(context): + code = context.code + response = context.client.get(f"/shorter/urls/{code}/stats/") + context.response = response + + +@when("I get invalid code stats") +def test_get_invalid_code_stats(context): + response = context.client.get("/shorter/urls/XXXX/stats/") + context.response = response + + +@when("I get a valid short URL with a valid code") +def test_get_invalid_code_stats(context): + response = context.client.get("/shorter/urls/") + context.response = response diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6658cee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +requests==2.21.0 +Flask==1.0.2 +flask-restplus==0.12.1 +pytest==4.2.0 +SQLAlchemy==1.3.0 +validators==0.13.0 +behave==1.2.6 +pytest-mock==1.10.0 +black diff --git a/server.py b/server.py new file mode 100644 index 0000000..c56ed49 --- /dev/null +++ b/server.py @@ -0,0 +1,3 @@ +from shorter_app.app import create_app + +app = create_app() diff --git a/server.sh b/server.sh new file mode 100755 index 0000000..6810a33 --- /dev/null +++ b/server.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +export FLASK_APP=server.py +export FLASK_ENV=development +flask run -p 5000 \ No newline at end of file diff --git a/shorter_app/__init__.py b/shorter_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shorter_app/apis/__init__.py b/shorter_app/apis/__init__.py new file mode 100644 index 0000000..92c6f1e --- /dev/null +++ b/shorter_app/apis/__init__.py @@ -0,0 +1,7 @@ +from flask_restplus import Api + +from shorter_app.apis.shorter_api import api as shorter_api + +api = Api(title="Shorter API", version="1.0", description="Shorter API") + +api.add_namespace(shorter_api) diff --git a/shorter_app/apis/shorter_api.py b/shorter_app/apis/shorter_api.py new file mode 100644 index 0000000..488ceb9 --- /dev/null +++ b/shorter_app/apis/shorter_api.py @@ -0,0 +1,122 @@ +import random +import string +from flask import redirect +from flask_restplus import Namespace, Resource, fields, marshal +from shorter_app.models import Shorter, Stats +from shorter_app.repositories import ShorterRepository, StatsRepository +from shorter_app.validator import Validator, ERROR_CODE_NOT_FOUND + +from datetime import datetime + +api = Namespace("shorter", description="Shorter App") + + +def get_current_time(): + return datetime.now() + + +def generate_shortcode(): + return "".join(random.choices(string.ascii_letters + string.digits, k=6)) + + +@api.route("/healthcheck/") +class HealtCheck(Resource): + def get(self): + return {"Service": "OK"} + + +request_shorter_url = api.model( + "Post Short URL (Request)", + { + "code": fields.String, + "url": fields.String, + }, +) + +response_shorter_url = api.model( + "Get Short URL list (Response)", + { + "code": fields.String, + "url": fields.String, + }, +) + +response_stats = api.model( + "Get Stats(Response)", + { + "created_at": fields.String, + "last_usage": fields.String, + "usage_count": fields.String, + }, +) + + +@api.route("/urls/") +class Url(Resource): + @api.doc("Get short url") + # TODO update response_shorter_url + @api.response(200, "Get Short URL list", response_shorter_url) + @api.response(401, "Unauthorized") + @api.response(400, "Bad Request") + def get(self): + shorter_list = ShorterRepository.get_all() + return marshal(shorter_list, response_shorter_url), 200 + + @api.doc("Post short url") + @api.expect(request_shorter_url) + @api.response(401, "Unauthorized") + @api.response(400, "Bad Request") + @api.response(409, "Duplicated code") + @api.response(201, "Short URL Created", response_shorter_url) + def post(self): + url = self.api.payload.get("url") + code = self.api.payload.get("code") + + error, status_code = Validator().validate_url(url) + if error: + return error, status_code + + if code: + error, status_code = Validator().validate_code(code) + if error: + return error, status_code + else: + code = generate_shortcode() + shorter = Shorter(url=url, code=code) + ShorterRepository.add(shorter) + created_at = get_current_time() + stats = Stats(code, created_at) + StatsRepository.add(stats) + return marshal(shorter, response_shorter_url), 201 + + +@api.route("/urls//") +class UrlItem(Resource): + @api.doc("Get short url by code") + @api.response(200, "Get Short URL list", response_shorter_url) + @api.response(401, "Unauthorized") + @api.response(404, "Not Found") + def get(self, code): + result = ShorterRepository.get(code) + + if not result: + return {"Error": ERROR_CODE_NOT_FOUND}, 404 + stats = StatsRepository.get(code) + stats.usage_count += 1 + stats.last_usage = get_current_time() + StatsRepository.commit() + return redirect(result.url) + + +@api.route("/urls//stats/") +class StatsItem(Resource): + @api.doc("Get code stast") + @api.response(200, "Get Short URL stasts", response_stats) + @api.response(401, "Unauthorized") + @api.response(404, "Not Found") + def get(self, code): + result = StatsRepository.get(code) + if not result: + return {"Error": ERROR_CODE_NOT_FOUND}, 404 + + return marshal(result, response_stats), 200 diff --git a/shorter_app/app.py b/shorter_app/app.py new file mode 100644 index 0000000..604cc1b --- /dev/null +++ b/shorter_app/app.py @@ -0,0 +1,24 @@ +from flask import Flask +from shorter_app.apis import api +from shorter_app.config import DefaultConfig +from shorter_app.database import db_session, init_db + + +def create_app(config=DefaultConfig): + app = Flask(__name__) + app.config.from_object(config) + api._doc = app.config.get("SWAGGER_DOC_PATH") + api.init_app( + app, + title="Shorter Application", + version="0.0.1", + description="Microservice for translate URL in a shorter URL", + ) + + init_db() + + @app.teardown_request + def teardown_request(request): + db_session.remove() + + return app diff --git a/shorter_app/config.py b/shorter_app/config.py new file mode 100644 index 0000000..e5a2190 --- /dev/null +++ b/shorter_app/config.py @@ -0,0 +1,33 @@ +import logging + + +class FixedConfig: + DEBUG = False + TESTING = False + JSON_AS_ASCII = False + USE_FILE_LOGGING = True + LOG_LOCATION = "./log/shorter.log" + LOG_MAX_BYTES = 100000 + LOG_BACKUP_COUNT = 2 + LOG_FORMAT = "%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]" + LOG_LEVEL = logging.DEBUG + SQLALCHEMY_ECHO = False + DATABASE_NAME = "shorter" + USE_API_STUBS = False + INTERNAL_API_TIMEOUT = 3.5 + SWAGGER_DOC_PATH = "/documents/" + + +class DefaultConfig(FixedConfig): + SERVER_NAME = "localhost:5000" + ENVIRONMENT_NAME = "development" + SQLALCHEMY_DATABASE_URI = "sqlite:////tmp/app.db" + BASE_URL = "" + + +class TestConfig(FixedConfig): + SERVER_NAME = "localhost:5000" + ENVIRONMENT_NAME = "local_test" + SQLALCHEMY_DATABASE_URI = "" + USE_LOCAL_MOCK_SERVER = True + BASE_URL = "shorter" diff --git a/shorter_app/database.py b/shorter_app/database.py new file mode 100644 index 0000000..61f3857 --- /dev/null +++ b/shorter_app/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +engine = create_engine("sqlite:////tmp/test2.db", convert_unicode=True) +db_session = scoped_session( + sessionmaker(autocommit=False, autoflush=False, bind=engine) +) +Base = declarative_base() +Base.query = db_session.query_property() + + +def init_db(): + # import all modules here that might define models so that + # they will be registered properly on the metadata. Otherwise + # you will have to import them first before calling init_db() + import shorter_app.models + + Base.metadata.create_all(bind=engine) diff --git a/shorter_app/models.py b/shorter_app/models.py new file mode 100644 index 0000000..3f5af30 --- /dev/null +++ b/shorter_app/models.py @@ -0,0 +1,32 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime +from shorter_app.database import Base +from datetime import datetime + + +class Shorter(Base): + __tablename__ = "shorter" + id = Column(Integer, primary_key=True) + url = Column(String, nullable=False) + code = Column(String, nullable=False, unique=True) + + def __init__(self, url, code): + self.url = url + self.code = code + + def as_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + + +class Stats(Base): + __tablename__ = "stats" + id = Column(Integer, primary_key=True) + code = Column(String, ForeignKey("shorter.code"), nullable=False) + usage_count = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=True, dt_format='rfc822') + last_usage = Column(DateTime, default=datetime.utcnow, nullable=True, dt_format='rfc822') + + def __init__(self, code, created_at): + self.code = code + self.created_at = created_at + self.last_usage = created_at + self.usage_count = 0 diff --git a/shorter_app/repositories.py b/shorter_app/repositories.py new file mode 100644 index 0000000..f6c603c --- /dev/null +++ b/shorter_app/repositories.py @@ -0,0 +1,38 @@ +from shorter_app.database import db_session +from shorter_app.models import Shorter, Stats + + +class BaseRespository: + @classmethod + def commit(cls): + db_session.commit() + + +class ShorterRepository(BaseRespository): + @classmethod + def add(cls, shorter): + db_session.add(shorter) + db_session.commit() + + @classmethod + def get(cls, code): + return Shorter.query.filter(Shorter.code == code).first() + + @classmethod + def get_all(cls): + return db_session.query(Shorter).all() + + +class StatsRepository(BaseRespository): + @classmethod + def add(self, stats): + db_session.add(stats) + db_session.commit() + + @classmethod + def get(self, code): + return Stats.query.filter(Stats.code == code).first() + + @classmethod + def get_all(self): + return db_session.query(Stats).all() diff --git a/shorter_app/validator.py b/shorter_app/validator.py new file mode 100644 index 0000000..0905b8c --- /dev/null +++ b/shorter_app/validator.py @@ -0,0 +1,38 @@ +import re +from shorter_app.repositories import ShorterRepository + +ERROR_INVALID_CODE = ( + "Invalid code. The code must comply with the following restrictions: " + "Alphanumeric string. " + "6 chars length. " + "Case-sensitive" +) +ERROR_DUPLICATED_CODE = "Code already registered. Try a new one" +ERROR_INVALID_URL = "An invalid URL was provided" +ERROR_CODE_NOT_FOUND = "Code was not found" +ERROR_URL_IS_REQUIRED = "URL is required" + + +class Validator: + def is_valid_code(self, code): + return code and len(code) == 6 and re.match("^[a-zA-Z0-9_]+$", code) + + def validate_url(self, url): + if not url: + return {"Error": ERROR_URL_IS_REQUIRED}, 400 + + import validators + if not validators.url(url): + return {"Error": ERROR_INVALID_URL}, 400 + + return None, None + + def validate_code(self, code): + if self.is_valid_code(code): + result = ShorterRepository.get(code) + if result: + return {"Error": ERROR_DUPLICATED_CODE}, 409 + else: + return None, None + else: + return {"Error": ERROR_INVALID_CODE}, 400 diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_api.py b/test/test_api.py new file mode 100644 index 0000000..831a863 --- /dev/null +++ b/test/test_api.py @@ -0,0 +1,183 @@ +import re +import datetime +from unittest.mock import MagicMock +from shorter_app.repositories import ShorterRepository, StatsRepository +from shorter_app.apis.shorter_api import generate_shortcode, Url, UrlItem, StatsItem +from shorter_app.validator import Validator +from shorter_app.models import Shorter, Stats +from shorter_app.validator import ( + ERROR_URL_IS_REQUIRED, + ERROR_DUPLICATED_CODE, + ERROR_INVALID_URL, + ERROR_INVALID_CODE, + ERROR_CODE_NOT_FOUND, +) + + +def mocked_get_current_time(): + return datetime.datetime(2020, 1, 1, 1, 1, 1) + + +class TestURLApi: + def test_generate_code(self): + code = generate_shortcode() + assert len(code) == 6 + assert re.match("^[a-zA-Z0-9_]+$", code) + + def test_get_shorter_list(self, mocker): + shorter_get_all_mock = mocker.patch.object( + ShorterRepository, "get_all", + return_value=[{"url": "url1", "code": "code1"}, + {"url": "url2", "code": "code2"}] + ) + url_api = Url() + response, status_code = url_api.get() + assert status_code == 200 + assert len(response) == 2 + assert response[0].get("code") == "code1" + assert response[0].get("url") == "url1" + assert response[1].get("code") == "code2" + assert response[1].get("url") == "url2" + shorter_get_all_mock.assert_called_once_with() + + def test_post_shorter_item(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="http://url.com", code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = None, None + validate_code_mock = mocker.patch.object(Validator, "validate_code") + validate_code_mock.return_value = None, None + #shorter_init_mock = mocker.patch.object( + # Shorter, "__init__", return_value=None + #) + # shorter_mock2 = mocker.patch("shorter_app.apis.shorter_api.Shorter") + stats_init_mock = mocker.patch.object( + Stats, "__init__", return_value=None + ) + shorter_repository_add_mock = mocker.patch.object( + ShorterRepository, "add" + ) + stats_repository_add_mock = mocker.patch.object( + StatsRepository, "add" + ) + response, status_code = url_api.post() + assert response.get("code") == "123456" + assert 201 == status_code + #shorter_init_mock.assert_called_once_with(url="http://url.com", code="123456") + # TODO check how to mock this function + # stats_init_mock.assert_called_once_with(response.get("code"), mocked_get_current_time()) + validate_url_mock.assert_called_once_with("http://url.com") + validate_code_mock.assert_called_once_with("123456") + + # TODO fix this calls + # shorter_repository_add_mock.assert_called_once_with(shorter_mock) + # stats_repository_add_mock.assert_called_once_with(stats_mock) + + def test_post_invalid_code(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="http://url.com", code="1") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = None, None + validate_code_mock = mocker.patch.object(Validator, "validate_code") + validate_code_mock.return_value = {"Error": ERROR_INVALID_CODE}, 400 + + response, status_code = url_api.post() + assert response.get("Error") == ERROR_INVALID_CODE + assert 400 == status_code + validate_url_mock.assert_called_once_with("http://url.com") + validate_code_mock.assert_called_once_with("1") + + def test_post_invalid_url(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="XXX", code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = {"Error": ERROR_INVALID_URL}, 400 + response, status_code = url_api.post() + assert response.get("Error") == ERROR_INVALID_URL + assert 400 == status_code + validate_url_mock.assert_called_once_with("XXX") + + def test_post_missing_url(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = {"Error": ERROR_URL_IS_REQUIRED}, 400 + response, status_code = url_api.post() + assert response.get("Error") == ERROR_URL_IS_REQUIRED + assert 400 == status_code + validate_url_mock.assert_called_once_with(None) + + def test_post_duplicated_item(self, mocker): + api_mock = MagicMock() + api_mock.payload = dict(url="http://url.com", code="123456") + url_api = Url(api=api_mock) + validate_url_mock = mocker.patch.object(Validator, "validate_url") + validate_url_mock.return_value = None, None + validate_code_mock = mocker.patch.object(Validator, "validate_code") + validate_code_mock.return_value = {"Error": ERROR_DUPLICATED_CODE}, 409 + response, status_code = url_api.post() + assert response.get("Error") == ERROR_DUPLICATED_CODE + assert 409 == status_code + validate_url_mock.assert_called_once_with("http://url.com") + validate_code_mock.assert_called_once_with("123456") + + def test_get_shorter_item(self, mocker): + url_api = UrlItem() + + query_mock = MagicMock() + repository_get_mock = mocker.patch.object( + ShorterRepository, "get", return_value=query_mock + ) + + stats_commit_mock = mocker.patch.object( + StatsRepository, "commit", return_value=query_mock + ) + + stats_get_mock = mocker.patch.object( + StatsRepository, "get", return_value=query_mock + ) + + stats_get_mock.usage_count = 0 + url_api.get("123456") + repository_get_mock.assert_called_once_with("123456") + stats_commit_mock.assert_called_once_with() + stats_get_mock.assert_called_once_with("123456") + + def test_get_shorter_item_not_found(self): + url_api = UrlItem() + response, status_code = url_api.get("XXXXXX") + assert status_code == 404 + + +class TestStatsApi: + def test_get_shorter_list(self, mocker): + stats_get_all_mock = mocker.patch.object( + StatsRepository, "get", + return_value=MagicMock(created_at="2019-09-03 00:00:00", + last_usage="2019-10-03 00:00:00", + usage_count=1) + + ) + mock_api = MagicMock(code="123456") + stats_api = StatsItem(mock_api) + response, status_code = stats_api.get("123456") + assert status_code == 200 + assert response.get("created_at") == "2019-09-03 00:00:00" + assert response.get("last_usage") == "2019-10-03 00:00:00" + assert response.get("usage_count") == '1' + stats_get_all_mock.assert_called_once_with("123456") + + def test_get_shorter_list_error(self, mocker): + stats_get_all_mock = mocker.patch.object( + StatsRepository, "get", + return_value=None) + mock_api = MagicMock(code="123456") + stats_api = StatsItem(mock_api) + response, status_code = stats_api.get("123456") + assert status_code == 404 + assert response.get("Error") == ERROR_CODE_NOT_FOUND + stats_get_all_mock.assert_called_once_with("123456") diff --git a/test/test_models.py b/test/test_models.py new file mode 100644 index 0000000..8e16911 --- /dev/null +++ b/test/test_models.py @@ -0,0 +1,2 @@ +class TestModel: + pass diff --git a/test/test_repository.py b/test/test_repository.py new file mode 100644 index 0000000..4a6de32 --- /dev/null +++ b/test/test_repository.py @@ -0,0 +1,2 @@ +class TestRepository: + pass diff --git a/test/test_validator.py b/test/test_validator.py new file mode 100644 index 0000000..4612ff4 --- /dev/null +++ b/test/test_validator.py @@ -0,0 +1,68 @@ +from unittest.mock import MagicMock +from shorter_app.repositories import ShorterRepository +from shorter_app.validator import ( + Validator, + ERROR_INVALID_URL, + ERROR_DUPLICATED_CODE, + ERROR_INVALID_CODE, + ERROR_URL_IS_REQUIRED, +) + + +class TestValidator: + def test_generate_code(self): + assert Validator().is_valid_code("ABC123") + assert Validator().is_valid_code("abc123") + assert Validator().is_valid_code("123456") + assert Validator().is_valid_code("ABCDEF") + assert Validator().is_valid_code("abcdef") + assert not Validator().is_valid_code("xxx") + assert not Validator().is_valid_code("xxxxxxxx") + assert not Validator().is_valid_code("!@@$") + + assert not Validator().is_valid_code("") + assert not Validator().is_valid_code(None) + + +class TestvalidateURL: + def test_validate_url(self): + error, status_code = Validator().validate_url("http://url.com") + assert not error + assert not status_code + + def test_validate_missing_url(self): + error, status_code = Validator().validate_url("") + assert error.get("Error") == ERROR_URL_IS_REQUIRED + assert status_code == 400 + + def test_validate_wrong_url(self): + error, status_code = Validator().validate_url("XXX") + assert error.get("Error") == ERROR_INVALID_URL + assert status_code == 400 + + +class TestvalidateCode: + def test_validate_code(self): + error, status_code = Validator().validate_code("ABCDEF") + assert not error + assert not status_code + + def test_validate_missing_code(self): + error, status_code = Validator().validate_code("") + assert error.get("Error") == ERROR_INVALID_CODE + assert status_code == 400 + + def test_validate_wrong_code(self): + error, status_code = Validator().validate_code("XXX") + assert error.get("Error") == ERROR_INVALID_CODE + assert status_code == 400 + + def test_validate_duplicated_code(self, mocker): + query_mock = MagicMock() + repository_get_mock = mocker.patch.object( + ShorterRepository, "get", return_value=query_mock + ) + error, status_code = Validator().validate_code("123456") + assert error.get("Error") == ERROR_DUPLICATED_CODE + assert status_code == 409 + repository_get_mock.assert_called_once_with("123456")