From 7d68b93238f2c7cd95a9cef16579f888d2d9e33b Mon Sep 17 00:00:00 2001 From: tomeros Date: Wed, 24 Mar 2021 15:11:34 +0200 Subject: [PATCH] Initial commit --- Dockerfile | 11 + LICENSE | 21 ++ Procfile | 1 + README.md | Bin 0 -> 17268 bytes app/__init__.py | 0 app/app.py | 5 + app/client/static/CSS/styles.css | 222 ++++++++++++++++++ app/client/static/JS/main.js | 58 +++++ app/client/static/favicon.ico | Bin 0 -> 67646 bytes app/client/templates/404.html | 28 +++ app/client/templates/api_doc.html | 68 ++++++ app/client/templates/base.html | 44 ++++ app/client/templates/error.html | 39 +++ app/client/templates/get_token.html | 42 ++++ app/client/templates/index.html | 57 +++++ app/client/templates/total-clicks.html | 26 ++ app/client/templates/verify.html | 55 +++++ app/client/templates/your_api_token.html | 35 +++ app/client/templates/your_short_url.html | 70 ++++++ app/server/__init__.py | 0 app/server/api/__init__.py | 0 app/server/api/api.py | 112 +++++++++ app/server/api/api_auth.py | 12 + app/server/db/__init__.py | 0 app/server/db/extensions.py | 4 + app/server/db/models.py | 87 +++++++ app/server/routes/api_doc.py | 9 + app/server/routes/error.py | 9 + app/server/routes/get_token.py | 9 + app/server/routes/index.py | 9 + app/server/routes/internal/__init__.py | 0 app/server/routes/internal/favicon.py | 13 + app/server/routes/internal/redirect_to_url.py | 24 ++ .../routes/internal/send_verification_code.py | 50 ++++ app/server/routes/internal/shorten_url.py | 40 ++++ app/server/routes/page_not_found.py | 9 + app/server/routes/total_clicks.py | 24 ++ app/server/routes/verify_code.py | 32 +++ app/server/routes/your_api_token.py | 15 ++ app/server/routes/your_short_url.py | 19 ++ app/setup/settings.py | 26 ++ app/setup/setup.py | 69 ++++++ app/tests/api_testing/__init__.py | 0 app/tests/api_testing/api_helper.py | 18 ++ app/tests/api_testing/settings.py | 26 ++ app/tests/api_testing/test_api.py | 81 +++++++ app/tests/front_end_testing/__init__.py | 0 app/tests/front_end_testing/index/__init__.py | 0 app/tests/front_end_testing/index/index.py | 40 ++++ .../front_end_testing/result/__init__.py | 0 app/tests/front_end_testing/result/result.py | 29 +++ app/tests/front_end_testing/test_front_end.py | 78 ++++++ .../total_clicks/__init__.py | 0 .../total_clicks/total_clicks.py | 12 + app/tests/utilities/__init__.py | 0 app/tests/utilities/logger.py | 20 ++ app/tests/utilities/selenium_utility.py | 137 +++++++++++ docker-compose.yml | 7 + requirements.txt | 40 ++++ run.py | 5 + 60 files changed, 1847 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Procfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/app.py create mode 100644 app/client/static/CSS/styles.css create mode 100644 app/client/static/JS/main.js create mode 100644 app/client/static/favicon.ico create mode 100644 app/client/templates/404.html create mode 100644 app/client/templates/api_doc.html create mode 100644 app/client/templates/base.html create mode 100644 app/client/templates/error.html create mode 100644 app/client/templates/get_token.html create mode 100644 app/client/templates/index.html create mode 100644 app/client/templates/total-clicks.html create mode 100644 app/client/templates/verify.html create mode 100644 app/client/templates/your_api_token.html create mode 100644 app/client/templates/your_short_url.html create mode 100644 app/server/__init__.py create mode 100644 app/server/api/__init__.py create mode 100644 app/server/api/api.py create mode 100644 app/server/api/api_auth.py create mode 100644 app/server/db/__init__.py create mode 100644 app/server/db/extensions.py create mode 100644 app/server/db/models.py create mode 100644 app/server/routes/api_doc.py create mode 100644 app/server/routes/error.py create mode 100644 app/server/routes/get_token.py create mode 100644 app/server/routes/index.py create mode 100644 app/server/routes/internal/__init__.py create mode 100644 app/server/routes/internal/favicon.py create mode 100644 app/server/routes/internal/redirect_to_url.py create mode 100644 app/server/routes/internal/send_verification_code.py create mode 100644 app/server/routes/internal/shorten_url.py create mode 100644 app/server/routes/page_not_found.py create mode 100644 app/server/routes/total_clicks.py create mode 100644 app/server/routes/verify_code.py create mode 100644 app/server/routes/your_api_token.py create mode 100644 app/server/routes/your_short_url.py create mode 100644 app/setup/settings.py create mode 100644 app/setup/setup.py create mode 100644 app/tests/api_testing/__init__.py create mode 100644 app/tests/api_testing/api_helper.py create mode 100644 app/tests/api_testing/settings.py create mode 100644 app/tests/api_testing/test_api.py create mode 100644 app/tests/front_end_testing/__init__.py create mode 100644 app/tests/front_end_testing/index/__init__.py create mode 100644 app/tests/front_end_testing/index/index.py create mode 100644 app/tests/front_end_testing/result/__init__.py create mode 100644 app/tests/front_end_testing/result/result.py create mode 100644 app/tests/front_end_testing/test_front_end.py create mode 100644 app/tests/front_end_testing/total_clicks/__init__.py create mode 100644 app/tests/front_end_testing/total_clicks/total_clicks.py create mode 100644 app/tests/utilities/__init__.py create mode 100644 app/tests/utilities/logger.py create mode 100644 app/tests/utilities/selenium_utility.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 run.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3368348 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3 + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "run.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cfc4126 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Tomer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..5b9ff7a --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn --bind 0.0.0.0:$PORT run:app \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6aa5cb1f4beb1485a6e2946ae7e76f18e0068217 GIT binary patch literal 17268 zcmdU%Yg3!Y6~~{~`V=bZj1#vKH(xVxJ77DGUEgYO^CHv1AcP_1772o#G+({#|F=hn zv(HTkV$aag7)kr=o;~;5uKb_>7Jn#yD8|L07!^ak4)uArI4Vwxe->Zr(}`Zk8vDAK z*XvZDhML(c_KJgIvv{v>&V|O*m-}{2UtY`Q2_jYklpBB={JwfN$qFzIN?&;Z3&zALRGtC;LHA`u1Q)`#C z@3HoOUwl<`i%$C2DZWUruIOG%w2p+|3qi^$HhPU0JU`I$zV_TJei6n%>*?RO#lChP z7yr`dx5b;{x!z;Vq1KEvvMC&nwR@-dM%?bDmFvYyv8wrx_579Kbc=_2e_5<+guiPV zVdh`73Vgd7+tmL1g4q=(chZWS!{fBWO^yE`*jt)&D!f^@o7TJ*E=R?W`aIA$w0@Dz z>rLkMit@a#1hqSP{VBozPEf{zFf6w9{b#+8^nFjGhZCszj9z6%GrTiFLW8=+_kuGL zKgSuYGx33^z3gcn&Z_9$6Mqbf>7!AJPTG@Rah4$MN`h6o9v2@HRak&g<2W=uVZHJC zji4V2^XI~RAQ|oJ^>Ko|J?mAT#o85d`{}tX_*}>9t?)5RGiu^{eeM%U!?bQQ(Sq)Q z+e*>}?0O{He$nX3ghuq^Nc?7HpFHpU)cVz zSm1_yPqg2OX6~QE>WcUKqG^!nj?M06vmJ7;G@E_gZbo{tD>&#kc5*6?nPrFdu3r9u&!;$w&^5X54Fg8tb1YuE2YIx2-Zx`51F^ z--MrW?@{(a7yA_;xql7G;$3;NB6;OL^GxGg8Z!@R+KN{(yXcBqaApLF=f2f+X+QDi zy`Gr&B^Ct+UuZn{Mcv{bf;JW>-NHNal^)~FpRv|j|}zwo4i{` zz6`&NMT|w~mLd#V=hDsX2Y8P37hK2(x}x0RlM%oR({wbP9mo3nUQlwcW>nOvF�Fw^saJUKfnP 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\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + let email = document.getElementById('email_input').value; + let isEmailValid = re.test(String(email).toLowerCase()); + + if (email.length < 1) { + $("div#cant-be-empty").fadeIn(300).delay(1500).fadeOut(900); + event.preventDefault(); + return false; + + } else if (isEmailValid === false) { + $("div#invalid-email").fadeIn(300).delay(1500).fadeOut(900); + event.preventDefault(); + return false; + } +} + +function callGetToken() { + let xhttp = new XMLHttpRequest(); + let hostUrl = document.getElementById('host-url').getAttribute('value'); + xhttp.onreadystatechange = function () { + if (this.readyState === 4 && this.status === 200) { + document.getElementById("token").innerHTML = this.responseText; + } + }; + xhttp.open("GET", `${hostUrl}/api/get_token`, true); + xhttp.send(); +} + +$('#verification').on('keyup', function () { + var foo = $(this).val().split(" ").join(""); + if (foo.length > 0) { + foo = foo.match(new RegExp('.{1,3}', 'g')).join(" "); + } + $(this).val(foo); +}); \ No newline at end of file diff --git a/app/client/static/favicon.ico b/app/client/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ce339358655bc5bc8f6c4338d47a9d6e4c7e19cd GIT binary patch literal 67646 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 literal 0 HcmV?d00001 diff --git a/app/client/templates/404.html b/app/client/templates/404.html new file mode 100644 index 0000000..ac9fff7 --- /dev/null +++ b/app/client/templates/404.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+

🌴 404

+

We could not find this page 🙁

+

+ + + + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/api_doc.html b/app/client/templates/api_doc.html new file mode 100644 index 0000000..ddc0a1d --- /dev/null +++ b/app/client/templates/api_doc.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+ +

The ShortMe API

+

ShortMe's API will allow you to access ShortMe's URL shortening capabilities over + API.

+

What you need to get started

+
    +
  • Access token
  • +
+ +

Shorten your first link!

+
    +
  1. Generate an access token.
  2. +
  3. You'll use the POST method to the /api/shorten endpoint. Append your + access token as a header in your request.
    + Here's an example: + <Authorization: Bearer {token}>

    + Example call:
    + POST http://shortme.biz/api/shorten?url=http://www.longurl.com

    + Example Response: +
    +
    {
    'short_url': shortme.biz/f3Jds,
    'original_url': 'http://www.longurl.com',
    'success':True
    }
    +
    +
  4. +
+
+ + +

Get total URL clicks

+

You can use ShortMe's API to track the total number of clicks your short URL received

+
    +
  1. You'll use the GET method to the /api/total_clicks endpoint. Access token + is not required.
    +
    + Example call:
    + GET http://shortme.biz/api/total_clicks?url=shortme.biz/f3Jds

    + Example Response: +
    +
    {
    'total': 2,
    'short_url': 'shortme.biz/f3Jds',
    'original_url': 'http://www.longurl.com',
    'success': True
    }
    +
    +
  2. +
+ + + {# #} + {# #} + {# #} + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/base.html b/app/client/templates/base.html new file mode 100644 index 0000000..95ebb28 --- /dev/null +++ b/app/client/templates/base.html @@ -0,0 +1,44 @@ + + + + + + URL shortner + + + + + + + + + + +
+
+
+

ShortMe

+

ShortMe is a free tool to shorten URLs. Create a short & memorable URL in seconds.

+
+
+
+ {% block content %} + {% endblock %} + + + + + + + + + + + \ No newline at end of file diff --git a/app/client/templates/error.html b/app/client/templates/error.html new file mode 100644 index 0000000..9c86c0f --- /dev/null +++ b/app/client/templates/error.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+
+

Yikes! We could not shorten this URL 🙁

+

 The URL you entered is not valid.

+
    +
  • Make sure + the website is online
  • +
  • Check if + the URL is valid
  • + +
  •  The + URL may have + been blocked
  • +
  •  The + url may have + been reported
  • +
+ + + + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/get_token.html b/app/client/templates/get_token.html new file mode 100644 index 0000000..d3276ee --- /dev/null +++ b/app/client/templates/get_token.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+
+ +

+

Get your free ShortMe's API token

+

Please enter your email below to get a verification code

+
+
+ + + +
+
+
+ +
+

*If you alredy verified your email you will get the API token

+
Please enter a valid email address +
+
This feild cannot be empty
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/index.html b/app/client/templates/index.html new file mode 100644 index 0000000..e28c23e --- /dev/null +++ b/app/client/templates/index.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} {% block content %} + + +
+
+
+
+
+
+ + + + + + +
+
This field cannot be empty
+
+
+
+
+
+ + +
+
+

+
Check out ShortMe's + API
+

+
+
+ +
+
+
with + by Tomer
+ +
Check out my GitHub
+
+
+ + +{% endblock %} diff --git a/app/client/templates/total-clicks.html b/app/client/templates/total-clicks.html new file mode 100644 index 0000000..21c0d4e --- /dev/null +++ b/app/client/templates/total-clicks.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} + +
+
+
+
+
+

Total URL Clicks

+

Your URL has been clicked {{ total_clicks }} times so far.

+ + + + + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/verify.html b/app/client/templates/verify.html new file mode 100644 index 0000000..15becbf --- /dev/null +++ b/app/client/templates/verify.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+
+ +

+

Please check your inbox

+

We've sent a verification code to your email. Please enter it below and click + verify.

+ +
+
+ + + + +
+
+
+ + + {% if is_verified == 'False' %} +
+ Your email already exist in our database but it's not activated.
Please check + your + inbox for the verification + code. +
+ {% endif %} + + {% if is_code_valid %} +
Wrong code, plesee try + again +
+ {% endif %} +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/your_api_token.html b/app/client/templates/your_api_token.html new file mode 100644 index 0000000..2fc65e8 --- /dev/null +++ b/app/client/templates/your_api_token.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+ +

+

Your API token

+ +
+ + + +
+
Token copied to clipboard
+
This feild cannot be empty
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/client/templates/your_short_url.html b/app/client/templates/your_short_url.html new file mode 100644 index 0000000..7ecbc1f --- /dev/null +++ b/app/client/templates/your_short_url.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block content %} + + + +
+
+
+
+
+

Your shortened URL

+

Copy the shortened link to share it.

+ +
+
+ + + + + + +
+
+ +
URL copied to clipboard
+ +
+ + + + +
+ +
+

Share your URL

+
+ + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/server/__init__.py b/app/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/api/__init__.py b/app/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/api/api.py b/app/server/api/api.py new file mode 100644 index 0000000..ebc625f --- /dev/null +++ b/app/server/api/api.py @@ -0,0 +1,112 @@ +# ------- standard library imports ------- +import requests + +# ------- 3rd party imports ------- +from flask_restful import Resource, reqparse + +# ------- local imports ------- +from app.server.db.extensions import db +from app.server.api.api_auth import auth +from app.server.db.models import Url, AuthToken + +shorten_parser = reqparse.RequestParser() +total_clicks_parser = reqparse.RequestParser() + +shorten_parser.add_argument('url', type=str, help='URL parameter is missing', required=True) +total_clicks_parser.add_argument('url', type=str, help='Short URL is missing', required=True) + + +class Shorten(Resource): + """ + Return a short URL from a given URL + URL: /api/shorten + METHOD: POST + PARAMS: url: long URL + HEADERS: Authorization: (Bearer ) + RETURN: dictionary with the short URL + """ + + decorators = [auth.login_required] + + @staticmethod + def post(): + args = shorten_parser.parse_args() + url = args['url'] + original_url = url if url.startswith('http') else ('http://' + url) + + try: + res = requests.get(original_url) + + if res.status_code == 200: + url = Url(original_url=original_url) + + db.session.add(url) + db.session.commit() + + return dict( + short_url=url.short_url, + original_url=url.original_url, + success=True + ), 200 + + else: + """in case of page_not_found response from the URL given""" + return dict( + success=False, + message='could not shorten this URL (page_not_found)' + ), 404 + + except (requests.exceptions.MissingSchema, requests.exceptions.ConnectionError, requests.exceptions.InvalidURL): + return dict( + success=False, + message='could not shorten this URL (page_not_found)' + ), 404 + + +class TotalClicks(Resource): + """ + return the total clicks of a short URL + URL: /api/total_clicks + METHOD: GET + PARAMS: short url + RETURN: dictionary with the total short url visits. + """ + + @staticmethod + def get(): + args = total_clicks_parser.parse_args() + url = args['url'].split('/')[-1] + + try: + url = Url.query.filter_by(short_url=url).first() + + return dict( + total=url.visits, + short_url=url.short_url, + original_url=url.original_url, + success=True + ), 200 + + except AttributeError: + return dict( + success=False, + message='could not find the URL (page_not_found)' + ), 404 + + +class GetToken(Resource): + """ + Return a unique API authorization token. Used only internaly by the web app. + URL: /api/get_token + METHOD: GET + RETURN: unique API authorization token + AuthToken: Required + """ + decorators = [auth.login_required] + + @staticmethod + def get(): + token = AuthToken() + db.session.add(token) + db.session.commit() + return str(token) diff --git a/app/server/api/api_auth.py b/app/server/api/api_auth.py new file mode 100644 index 0000000..b964e06 --- /dev/null +++ b/app/server/api/api_auth.py @@ -0,0 +1,12 @@ +from flask_httpauth import HTTPTokenAuth + +from app.server.db.models import AuthToken + +auth = HTTPTokenAuth(scheme='Bearer') + + +@auth.verify_token +def verify_token(token): + """Used to verify user's Token""" + return AuthToken.query.filter_by(auth_token=token).first() + diff --git a/app/server/db/__init__.py b/app/server/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/db/extensions.py b/app/server/db/extensions.py new file mode 100644 index 0000000..a51e6c6 --- /dev/null +++ b/app/server/db/extensions.py @@ -0,0 +1,4 @@ +# ------- 3rd party imports ------- +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/app/server/db/models.py b/app/server/db/models.py new file mode 100644 index 0000000..d468ec0 --- /dev/null +++ b/app/server/db/models.py @@ -0,0 +1,87 @@ +# ------- standard library imports ------- +import random +import string +import secrets +from random import choices +from datetime import datetime + +# ------- local imports ------- +from sqlalchemy import ForeignKey +from .extensions import db + + +class Url(db.Model): + id = db.Column(db.Integer, primary_key=True) + original_url = db.Column(db.String(512)) + short_url = db.Column(db.String(5), unique=True) + visits = db.Column(db.Integer, default=0) + date_created = db.Column(db.DateTime, default=datetime.now) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.short_url = self.generate_short_url() + + def generate_short_url(self): + characters = string.digits + string.ascii_letters + short_url = ''.join(choices(characters, k=5)) + url = self.query.filter_by(short_url=short_url).first() + + if url: + return self.generate_short_url() + + return short_url + + def __repr__(self): + return f'{self.original_url}, {self.visits}' + + +class AuthToken(db.Model): + __tablename__ = 'auth_token' + id = db.Column(db.Integer, primary_key=True) + auth_token = db.Column(db.String(16)) + date_created = db.Column(db.DateTime, default=datetime.now) + email_id = db.Column(db.Integer, ForeignKey('email.id')) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.auth_token = self.generate_auth_token() if self.auth_token is None else self.auth_token + + @staticmethod + def generate_auth_token(): + generated_token = secrets.token_hex(16) + return generated_token + + def __repr__(self): + return f'{self.auth_token}' + + +class Email(db.Model): + __tablename__ = 'email' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(512), unique=True) + date_created = db.Column(db.DateTime, default=datetime.now) + is_verified = db.Column(db.Boolean, default=False) + verification_code = db.relationship('VerificationCode', backref='email') + auth_token = db.relationship('AuthToken', backref='email') + + def __repr__(self): + return f'{self.email}, {self.verification_code}, {self.auth_token}' + + +class VerificationCode(db.Model): + __tablename__ = 'verification_code' + id = db.Column(db.Integer, primary_key=True) + email_id = db.Column(db.Integer, ForeignKey('email.id')) + verification_code = db.Column(db.String, unique=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.verification_code = self.generate_verification_code() + + @staticmethod + def generate_verification_code(): + verification_code = ' '.join([str(random.randint(0, 999)).zfill(3) for _ in range(2)]) + return verification_code + + def __repr__(self): + return f'{self.verification_code}' diff --git a/app/server/routes/api_doc.py b/app/server/routes/api_doc.py new file mode 100644 index 0000000..bfaae21 --- /dev/null +++ b/app/server/routes/api_doc.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +api_doc_blueprint = Blueprint('api_doc_blueprint', __name__, template_folder='templates') + + +@api_doc_blueprint.route('/api_doc') +def api_doc(): + return render_template('api_doc.html') diff --git a/app/server/routes/error.py b/app/server/routes/error.py new file mode 100644 index 0000000..b4f420b --- /dev/null +++ b/app/server/routes/error.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +error_blueprint = Blueprint('error_blueprint', __name__, template_folder='templates') + + +@error_blueprint.route('/error') +def error(): + return render_template('error.html') diff --git a/app/server/routes/get_token.py b/app/server/routes/get_token.py new file mode 100644 index 0000000..46dc32f --- /dev/null +++ b/app/server/routes/get_token.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +get_token_blueprint = Blueprint('get_token_blueprint', __name__, template_folder='templates') + + +@get_token_blueprint.route('/get_token') +def get_token(): + return render_template('get_token.html') diff --git a/app/server/routes/index.py b/app/server/routes/index.py new file mode 100644 index 0000000..ca612f6 --- /dev/null +++ b/app/server/routes/index.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import render_template, Blueprint + +index_blueprint = Blueprint('index_blueprint', __name__, template_folder='templates') + + +@index_blueprint.route("/") +def index(): + return render_template('index.html') diff --git a/app/server/routes/internal/__init__.py b/app/server/routes/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/server/routes/internal/favicon.py b/app/server/routes/internal/favicon.py new file mode 100644 index 0000000..186fcee --- /dev/null +++ b/app/server/routes/internal/favicon.py @@ -0,0 +1,13 @@ +# ------- standard library imports ------- +import os + +# ------- 3rd party imports ------- +from flask import Blueprint, send_from_directory + +app_blueprint = Blueprint('app_blueprint', __name__) + + +@app_blueprint.route('/favicon.ico') +def favicon(): + return send_from_directory(os.path.join(app_blueprint.root_path, 'static'), + 'favicon.ico', mimetype='image/vnd.microsoft.icon') diff --git a/app/server/routes/internal/redirect_to_url.py b/app/server/routes/internal/redirect_to_url.py new file mode 100644 index 0000000..24fc93a --- /dev/null +++ b/app/server/routes/internal/redirect_to_url.py @@ -0,0 +1,24 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, redirect, url_for + +# ------- local imports ------- +from app.server.db.models import Url +from app.server.db.extensions import db + +redirect_to_url_blueprint = Blueprint('redirect_to_url_blueprint', __name__) + + +@redirect_to_url_blueprint.route('/') +def redirect_to_url(short_url): + """ + This function will query the database with the short_url and will + redirect to the original url if it exist in the database. + """ + url = Url.query.filter_by(short_url=short_url).first() + + if url: + url.visits = url.visits + 1 + db.session.commit() + return redirect(url.original_url) + + return redirect(url_for('page_not_found_blueprint.page_not_found')) diff --git a/app/server/routes/internal/send_verification_code.py b/app/server/routes/internal/send_verification_code.py new file mode 100644 index 0000000..4e4af4a --- /dev/null +++ b/app/server/routes/internal/send_verification_code.py @@ -0,0 +1,50 @@ +# ------- standard library imports ------- +import re + +# ------- 3rd party imports ------- +from flask_mail import Mail, Message +from flask import Blueprint, request, redirect, url_for, session + +# ------- local imports ------- +from app import app +from app.server.db.extensions import db +from app.server.db.models import VerificationCode, Email + +send_otp_blueprint = Blueprint('send_otp_blueprint', __name__) + + +@send_otp_blueprint.route('/email_validation', methods=['POST']) +def email_validation(): + input_email = request.form['email'] + pattern = r'[^@]+@[^@]+\.[^@]+' + is_email_valid = re.match(pattern, input_email) + + if is_email_valid: + email = Email.query.filter_by(email=input_email).first() + + if email and email.is_verified: + auth_token = Email.query.filter_by(email=input_email).first().auth_token + return redirect(url_for('your_api_token_blueprint.your_api_token', auth_token=auth_token)) + + elif email and not email.is_verified: + return redirect(url_for('verify_code_blueprint.enter_verification_code', is_verified=False)) + + else: + if not email: + session['user_email'] = input_email + + email = Email(email=input_email) + verification_code = VerificationCode(email=email) + db.session.add(verification_code, email) + db.session.commit() + + # print('user added to db') + # print('verification_code:') + # print(verification_code) + # print('*' * 33) + + mail = Mail(app.app) + msg = Message('ShortMe Verification Code', sender='shortme.biz@gmail.com', recipients=[email.email]) + msg.body = str(f'Hi!\nThis is your verification code: {verification_code.verification_code}') + mail.send(msg) + return redirect(url_for('verify_code_blueprint.enter_verification_code')) diff --git a/app/server/routes/internal/shorten_url.py b/app/server/routes/internal/shorten_url.py new file mode 100644 index 0000000..99320ac --- /dev/null +++ b/app/server/routes/internal/shorten_url.py @@ -0,0 +1,40 @@ +# ------- standard library imports ------- +import json +import requests + +# ------- 3rd party imports ------- +import flask +from flask import Blueprint, request, redirect, url_for + +# ------- local imports ------- +from app import app + +shorten_url_blueprint = Blueprint('shorten_url_blueprint', __name__, template_folder='templates') + + +@shorten_url_blueprint.route('/shorten', methods=['POST']) +def shorten_url(): + base_url = flask.url_for("index_blueprint.index", _external=True) + original_url = request.form['original_url'] + shorten_endpoint = base_url + 'api/shorten' + + params = { + 'url': original_url + } + + headers = { + 'Authorization': f'Bearer {app.app.secret_key}' + } + + response = requests.post(shorten_endpoint, headers=headers, params=params) + + if response.status_code == 200: + response = json.loads(response.text) + short_url = response['short_url'] + original_url = response['original_url'] + + return redirect(url_for('your_short_url_blueprint.your_short_url', + short_url=short_url, + original_url=original_url)) + else: + return redirect(url_for('error_blueprint.error')) diff --git a/app/server/routes/page_not_found.py b/app/server/routes/page_not_found.py new file mode 100644 index 0000000..a3c270e --- /dev/null +++ b/app/server/routes/page_not_found.py @@ -0,0 +1,9 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template + +page_not_found_blueprint = Blueprint('page_not_found_blueprint', __name__, template_folder='templates') + + +@page_not_found_blueprint.route('/page_not_found') +def page_not_found(): + return render_template('404.html') diff --git a/app/server/routes/total_clicks.py b/app/server/routes/total_clicks.py new file mode 100644 index 0000000..66ca62d --- /dev/null +++ b/app/server/routes/total_clicks.py @@ -0,0 +1,24 @@ +# ------- standard library imports ------- +import json +import requests + +# ------- 3rd party imports ------- +import flask +from flask import Blueprint, render_template, request + +total_clicks_blueprint = Blueprint('total_clicks_blueprint', __name__, template_folder='templates') + + +@total_clicks_blueprint.route('/total_clicks') +def total_clicks(): + short_url = request.args['short_url'] + base_url = flask.url_for("index_blueprint.index", _external=True) + total_clicks_endpoint = base_url + 'api/total_clicks' + + params = { + 'url': short_url + } + + response = requests.get(total_clicks_endpoint, params=params) + total_url_clicks = json.loads(response.text)['total'] + return render_template('total-clicks.html', total_clicks=total_url_clicks) diff --git a/app/server/routes/verify_code.py b/app/server/routes/verify_code.py new file mode 100644 index 0000000..14843da --- /dev/null +++ b/app/server/routes/verify_code.py @@ -0,0 +1,32 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template, request, redirect, url_for, session + +# ------- local imports ------- +from app.server.db.extensions import db +from app.server.db.models import VerificationCode, AuthToken, Email + +verify_code_blueprint = Blueprint('verify_code_blueprint', __name__, template_folder='templates') + + +@verify_code_blueprint.route('/verify', methods=['GET', 'POST']) +def enter_verification_code(): + is_verified = request.args.get('is_verified') + is_code_valid = request.args.get('is_code_valid') + return render_template('verify.html', is_code_valid=is_code_valid, is_verified=is_verified) + + +@verify_code_blueprint.route('/validate_code', methods=['POST']) +def validate_code(): + input_code = request.form['verification'] + code = VerificationCode.query.filter_by(verification_code=input_code).first() + + if code: + email = Email.query.filter_by(email=session['user_email']).first() + email.is_verified = True + auth_token = AuthToken(email=email) + db.session.add(auth_token) + db.session.commit() + return redirect(url_for('your_api_token_blueprint.your_api_token', auth_token=auth_token)) + + else: + return redirect(url_for('verify_code_blueprint.enter_verification_code', is_code_valid=False)) diff --git a/app/server/routes/your_api_token.py b/app/server/routes/your_api_token.py new file mode 100644 index 0000000..8be00a1 --- /dev/null +++ b/app/server/routes/your_api_token.py @@ -0,0 +1,15 @@ +# ------- 3rd party imports ------- +from flask import Blueprint, render_template, request, url_for + +your_api_token_blueprint = Blueprint('your_api_token_blueprint', __name__, template_folder='templates') + + +@your_api_token_blueprint.route('/your_api_token') +def your_api_token(): + auth_token = request.args.get("auth_token") + + if auth_token: + return render_template('your_api_token.html', auth_token=auth_token) + + else: + return render_template(url_for('page_not_found_blueprint.page_not_found')) diff --git a/app/server/routes/your_short_url.py b/app/server/routes/your_short_url.py new file mode 100644 index 0000000..97ac9d0 --- /dev/null +++ b/app/server/routes/your_short_url.py @@ -0,0 +1,19 @@ +# ------- 3rd party imports ------- +import flask +from flask import Blueprint, render_template, request + +your_short_url_blueprint = Blueprint('your_short_url_blueprint', __name__, template_folder='templates') + + +@your_short_url_blueprint.route('/your_short_url') +def your_short_url(): + original_url = request.args['original_url'] + short_url = request.args['short_url'] + base_url = flask.url_for("index_blueprint.index", _external=True) + full_short_url = f'{base_url}{short_url}' + full_short_url = full_short_url.replace('http://www.', '') + + return render_template('your_short_url.html', + original_url=original_url, + short_url=short_url, + full_short_url=full_short_url) diff --git a/app/setup/settings.py b/app/setup/settings.py new file mode 100644 index 0000000..277aa09 --- /dev/null +++ b/app/setup/settings.py @@ -0,0 +1,26 @@ +import os + +SECRET_KEY = os.environ.get('SECRET_KEY') +SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') +SQLALCHEMY_TRACK_MODIFICATIONS = os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS') +ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME') +ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') + +MAIL_SERVER = os.environ.get('MAIL_SERVER') +MAIL_PORT = int(os.environ.get('MAIL_PORT')) +MAIL_USERNAME = os.environ.get('MAIL_USERNAME') +MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') +MAIL_USE_TLS = False +MAIL_USE_SSL = True + +""" +:::::::: .env file content :::::::: +SQLALCHEMY_DATABASE_URI=sqlite:///db.sqlite3 +SQLALCHEMY_TRACK_MODIFICATIONS=False +SECRET_KEY=randomKey + +MAIL_SERVER=smtp.gmail.com +MAIL_PORT=465 +MAIL_USERNAME=email@email.com +MAIL_PASSWORD=password +""" diff --git a/app/setup/setup.py b/app/setup/setup.py new file mode 100644 index 0000000..5c79885 --- /dev/null +++ b/app/setup/setup.py @@ -0,0 +1,69 @@ +# ------- standard library imports ------- +import os + +# ------- 3rd party imports ------- +from flask import Flask +from flask_restful import Api +from dotenv import load_dotenv + +# ------- local imports ------- +from app.server.db.extensions import db +from app.server.db.models import AuthToken +from app.server.routes.index import index_blueprint +from app.server.routes.internal.redirect_to_url import redirect_to_url_blueprint +from app.server.routes.internal.favicon import app_blueprint +from app.server.routes.internal.send_verification_code import send_otp_blueprint +from app.server.routes.internal.shorten_url import shorten_url_blueprint +from app.server.routes.your_short_url import your_short_url_blueprint +from app.server.routes.total_clicks import total_clicks_blueprint +from app.server.routes.error import error_blueprint +from app.server.routes.page_not_found import page_not_found_blueprint +from app.server.routes.api_doc import api_doc_blueprint +from app.server.routes.get_token import get_token_blueprint +from app.server.routes.your_api_token import your_api_token_blueprint +from app.server.routes.verify_code import verify_code_blueprint + +from app.server.api.api import Shorten, TotalClicks, GetToken + + +def create_app(config_file): + """ + Creating and returning the app + """ + app_path = os.path.dirname(os.path.abspath(__file__)) + project_folder = os.path.expanduser(app_path) + load_dotenv(os.path.join(project_folder, '.env')) + + app = Flask(__name__, template_folder='../client/templates', static_folder='../client/static') + api = Api(app) + app.config.from_pyfile(config_file) + + db.init_app(app) + + with app.app_context(): + db.drop_all() + db.create_all() + + app_auth_token = app.secret_key + auth_token = AuthToken(auth_token=app_auth_token) + db.session.add(auth_token) + db.session.commit() + + api.add_resource(Shorten, '/api/shorten') + api.add_resource(GetToken, '/api/get_token') + api.add_resource(TotalClicks, '/api/total_clicks') + + app.register_blueprint(index_blueprint) + app.register_blueprint(page_not_found_blueprint) + app.register_blueprint(redirect_to_url_blueprint) + app.register_blueprint(your_short_url_blueprint) + app.register_blueprint(total_clicks_blueprint) + app.register_blueprint(error_blueprint) + app.register_blueprint(app_blueprint) + app.register_blueprint(api_doc_blueprint) + app.register_blueprint(get_token_blueprint) + app.register_blueprint(send_otp_blueprint) + app.register_blueprint(verify_code_blueprint) + app.register_blueprint(your_api_token_blueprint) + app.register_blueprint(shorten_url_blueprint) + return app diff --git a/app/tests/api_testing/__init__.py b/app/tests/api_testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/api_testing/api_helper.py b/app/tests/api_testing/api_helper.py new file mode 100644 index 0000000..7ea6996 --- /dev/null +++ b/app/tests/api_testing/api_helper.py @@ -0,0 +1,18 @@ +import json +from app.app import app as a + + +class ApiHelper: + HEADERS = { + 'Authorization': f'Bearer {a.secret_key}' + } + + def get_auth_token(self, app): + r = app.test_client().get( + '/api/get_token', headers=self.HEADERS + ) + + print(r.get_data(as_text=True)) + + key = json.loads(r.get_data(as_text=True)) + return key diff --git a/app/tests/api_testing/settings.py b/app/tests/api_testing/settings.py new file mode 100644 index 0000000..277aa09 --- /dev/null +++ b/app/tests/api_testing/settings.py @@ -0,0 +1,26 @@ +import os + +SECRET_KEY = os.environ.get('SECRET_KEY') +SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') +SQLALCHEMY_TRACK_MODIFICATIONS = os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS') +ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME') +ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD') + +MAIL_SERVER = os.environ.get('MAIL_SERVER') +MAIL_PORT = int(os.environ.get('MAIL_PORT')) +MAIL_USERNAME = os.environ.get('MAIL_USERNAME') +MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') +MAIL_USE_TLS = False +MAIL_USE_SSL = True + +""" +:::::::: .env file content :::::::: +SQLALCHEMY_DATABASE_URI=sqlite:///db.sqlite3 +SQLALCHEMY_TRACK_MODIFICATIONS=False +SECRET_KEY=randomKey + +MAIL_SERVER=smtp.gmail.com +MAIL_PORT=465 +MAIL_USERNAME=email@email.com +MAIL_PASSWORD=password +""" diff --git a/app/tests/api_testing/test_api.py b/app/tests/api_testing/test_api.py new file mode 100644 index 0000000..8a9b766 --- /dev/null +++ b/app/tests/api_testing/test_api.py @@ -0,0 +1,81 @@ +# ------- standard library imports ------- +import json +import unittest + +# ------- local imports ------- +from time import sleep + +from app.server.db.extensions import db +from app.server.db.models import Url +from app.app import create_app +from app.tests.api_testing.api_helper import ApiHelper + + +class TestApp(unittest.TestCase): + VALID_URL = 'youtube.com' + INVALID_URL = 'www.youtube.com/what?a=b&c=d' + INVALID_PARAM = 'INVALID' + + def setUp(self): + self.helper = ApiHelper() + self.app = create_app(config_file='settings.py') + sleep(1) + self.key = self.helper.get_auth_token(self.app) + + def test_01_shorten_url_success(self): + response = self.app.test_client().post( + '/api/shorten', + headers={'Authorization': f'Bearer {self.key}'}, + data={'url': self.VALID_URL} + ) + + res_dict = json.loads(response.get_data(as_text=True)) + + short_url = res_dict['short_url'] + original_url = res_dict['original_url'] + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(short_url), 5) + self.assertEqual(original_url, 'http://youtube.com') + + def test_02_shorten_url_fail(self): + response = self.app.test_client().post( + '/api/shorten', + headers={'Authorization': f'Bearer {self.key}'}, + data={'url': self.INVALID_URL}, + ) + + res_dict = json.loads(response.get_data(as_text=True)) + + self.assertEqual(response.status_code, 404) + self.assertEqual(res_dict['success'], False) + self.assertEqual(res_dict['message'], 'could not shorten this URL (page_not_found)') + + def test_03_total_clicks(self): + # add url to db + response = self.app.test_client().post( + '/api/shorten', + headers={'Authorization': f'Bearer {self.key}'}, + data={'url': 'youtube.com'}, + ) + + with self.app.app_context(): + url = Url.query.filter_by(original_url='http://youtube.com').first() + short_url = url.short_url + + response = self.app.test_client().get( + '/api/total_clicks', + data={'url': short_url}, + ) + + res_dict = json.loads(response.get_data(as_text=True)) + self.assertEqual(res_dict['total'], 0) + + def tearDown(self): + db.session.remove() + with self.app.app_context(): + db.drop_all() + + +if __name__ == '__main__': + unittest.main() diff --git a/app/tests/front_end_testing/__init__.py b/app/tests/front_end_testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/index/__init__.py b/app/tests/front_end_testing/index/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/index/index.py b/app/tests/front_end_testing/index/index.py new file mode 100644 index 0000000..d405335 --- /dev/null +++ b/app/tests/front_end_testing/index/index.py @@ -0,0 +1,40 @@ +from app.tests.utilities import selenium_utility + + +class Index(selenium_utility.SeleniumUtility): + _heading_locator = '//p[@id="heading-p"]' + _url_input_locator = '//input[@id="url-input"]' + _shorten_button_locator = '//button[@type="submit"]' + _enter_url_warning = '//div[@class="alert-box alert-warning"]' + _try_again_button = '//button[@id="try-again-btn"]' + + def __init__(self, driver): + self.driver = driver + super().__init__(driver) + self.url_input = self.get_element(self._url_input_locator) + self.shorten_button = self.get_element(self._shorten_button_locator) + + def get_heading_text(self): + return self.get_element(self._heading_locator).text + + def enter_valid_url(self): + self.url_input = self.get_element(self._url_input_locator) + self.url_input.click() + self.url_input.send_keys('youtube.com') + + def enter_invalid_url(self): + self.url_input.click() + self.url_input.send_keys('https://www.youtube.com/what?a=b&c=d') + + def click_shorten_button(self): + self.shorten_button = self.get_element(self._shorten_button_locator) + self.shorten_button.click() + + def check_warning_present(self): + return self.get_element(self._enter_url_warning).is_displayed() + + def get_current_url(self): + return self.driver.current_url.split('/')[-1] + + def click_try_again(self): + self.wait_for_element(self._try_again_button).click() diff --git a/app/tests/front_end_testing/result/__init__.py b/app/tests/front_end_testing/result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/result/result.py b/app/tests/front_end_testing/result/result.py new file mode 100644 index 0000000..f35a1a9 --- /dev/null +++ b/app/tests/front_end_testing/result/result.py @@ -0,0 +1,29 @@ +from app.tests.utilities import selenium_utility +import pyperclip as pc + + +class Result(selenium_utility.SeleniumUtility): + _url_input_locator = '//input[@id="copy-able"]' + _copy_button_locator = '//button[@id="copy-btn"]' + _total_clicks_url = '//a[@id="total-clicks-link"]' + + def __init__(self, driver): + self.driver = driver + super().__init__(driver) + + self.url_input = self.get_element(self._url_input_locator) + self.copy_button = self.get_element(self._copy_button_locator) + + def get_input_text(self): + return self.get_element(self._url_input_locator).get_attribute('value') + + def click_copy_button(self): + self.copy_button.click() + + def go_to_total_clicks(self): + self.get_element(self._total_clicks_url).click() + + @staticmethod + def get_clipboard_content(): + text = pc.paste() + return text diff --git a/app/tests/front_end_testing/test_front_end.py b/app/tests/front_end_testing/test_front_end.py new file mode 100644 index 0000000..22b9e9c --- /dev/null +++ b/app/tests/front_end_testing/test_front_end.py @@ -0,0 +1,78 @@ +# ------- standard library imports ------- +import unittest +import multiprocessing + +# ------- 3rd party imports ------- +from selenium import webdriver +from flask_testing import LiveServerTestCase + +# ------- local imports ------- +from app.app import create_app +from app.tests.front_end_testing.index.index import Index +from app.tests.front_end_testing.result.result import Result +from app.tests.front_end_testing.total_clicks.total_clicks import TotalClicks + + +class TestAppSuccess(LiveServerTestCase, unittest.TestCase): + multiprocessing.set_start_method("fork") + + def create_app(self): + app = create_app(config_file='tests/front_end_testing/settings.py') + app.testing = True + app.config.update(LIVESERVER_PORT=5002) + return app + + @classmethod + def setUpClass(cls): + cls.chrome_browser = webdriver.Chrome() + + def test(self): + try: + """Test the index page.""" + self.chrome_browser.get(self.get_server_url()) + index = Index(self.chrome_browser) + # test that empty input shows error + index.click_shorten_button() + self.assertEqual(index.check_warning_present(), True) + + # test that an invalid url redirecrt to error page + index.enter_invalid_url() + index.click_shorten_button() + self.assertEqual(index.get_current_url(), 'error') + # Go back to home page + index.click_try_again() + + # check if heading is present + heading = index.get_heading_text() + self.assertEqual(heading, + 'ShortMe is a free tool to shorten URLs. Create a short & memorable URL in seconds.') + + # test that a valid URL is working + index.enter_valid_url() + index.click_shorten_button() + + """Test the result page once the long URL has been shortened""" + result = Result(self.chrome_browser) + # test that the short URL exist inside the input element + short_url = result.get_input_text() + self.assertIsNotNone(short_url) + + # test that the copy button works + result.click_copy_button() + clipboard_content = result.get_clipboard_content() + self.assertEqual(clipboard_content, short_url) + + # go to the total_clicks page + result.go_to_total_clicks() + + total_clicks = TotalClicks(self.chrome_browser) + # test that the text matches the expected + p_text = total_clicks.get_total_paragraph_text() + self.assertEqual(p_text, 'Your URL has been clicked 0 times so far.') + + finally: + self.chrome_browser.quit() + + +if __name__ == '__main__': + unittest.main() diff --git a/app/tests/front_end_testing/total_clicks/__init__.py b/app/tests/front_end_testing/total_clicks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/front_end_testing/total_clicks/total_clicks.py b/app/tests/front_end_testing/total_clicks/total_clicks.py new file mode 100644 index 0000000..4470581 --- /dev/null +++ b/app/tests/front_end_testing/total_clicks/total_clicks.py @@ -0,0 +1,12 @@ +from app.tests.utilities import selenium_utility + + +class TotalClicks(selenium_utility.SeleniumUtility): + _url_input_locator = '//p[@id="total-p"]' + + def __init__(self, driver): + self.driver = driver + super().__init__(driver) + + def get_total_paragraph_text(self): + return self.get_element(self._url_input_locator).text diff --git a/app/tests/utilities/__init__.py b/app/tests/utilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/utilities/logger.py b/app/tests/utilities/logger.py new file mode 100644 index 0000000..d7d5725 --- /dev/null +++ b/app/tests/utilities/logger.py @@ -0,0 +1,20 @@ +import inspect +import logging + + +def Logger(log_level=logging.INFO): + # Gets the name of the class / method from where this method is called + logger_name = inspect.stack()[1][3] + logger = logging.getLogger(logger_name) + # By default, log all messages + logger.setLevel(logging.DEBUG) + + file_handler = logging.FileHandler("{0}.log".format(logger_name), mode='w') + file_handler.setLevel(log_level) + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s: %(message)s', + datefmt='%m/%d/%Y %I:%M:%S %p') + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger diff --git a/app/tests/utilities/selenium_utility.py b/app/tests/utilities/selenium_utility.py new file mode 100644 index 0000000..5d9d36d --- /dev/null +++ b/app/tests/utilities/selenium_utility.py @@ -0,0 +1,137 @@ +# ------- standard library imports ------- +import logging +from pathlib import Path + +# ------- 3rd party imports ------- +from selenium.common.exceptions import * +from selenium.webdriver.common.by import By +from time import sleep, strftime, localtime +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.ui import Select +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains + +# ------- local imports ------- +from app.tests.utilities import logger + + +class SeleniumUtility: + """ + This utilitie is used to make it easier to use Selenium + """ + + log = logger.Logger(logging.DEBUG) + + def __init__(self, driver): + self.driver = driver + self.actions = ActionChains(driver) + + @staticmethod + def _get_by_type(locator_type): + """Returning the By type. xpath -> By.XPATH""" + return getattr(By, locator_type.upper()) + + def send_key_command(self, element, key): + """Sending key commands to a pre-found element. Keys.RETURN""" + try: + element.send_keys(getattr(Keys, key.upper())) + self.log.info(f'Key: {key} sent to element: {element}') + + except AttributeError as e: + print(e) + self.log.info(f'Could not send keys to {element}') + + def take_screenshot(self, sleep_time=0): + sleep(sleep_time) + Path("screenshots").mkdir(exist_ok=True) + t = localtime() + current_time = str(strftime("%H:%M:%S", t)) + file_name = ''.join([current_time, '.png']) + screenshot_directory = "screenshots" + destination_file = '/'.join([screenshot_directory, file_name]) + self.driver.save_screenshot(destination_file) + self.log.info(f'screenshot saved to {destination_file}') + + def get_element(self, locator, locator_type='xpath'): + """Return found element""" + by_type = self._get_by_type(locator_type) + + try: + element = self.driver.find_element(by_type, locator) + self.log.info(f'Element found. Locator: {locator}, Loctor type: {locator_type}') + return element + + except NoSuchElementException as e: + print(e) + self.log.info(f'Element not found. Locator: {locator}, Loctor type: {locator_type}') + + except Exception as e: + print(e) + self.log.info(f'Error while locating {locator}. {e}') + + def get_elements(self, locator, locator_type='xpath'): + """Return matching elements""" + by_type = self._get_by_type(locator_type) + try: + elements = self.driver.find_elements(by_type, locator) + return elements + + except NoSuchElementException as e: + print(e) + self.log.info(f'Element not found. Locator: {locator}, Loctor type: {locator_type}') + + except Exception as e: + print(e) + self.log.info(f'Error while locating {locator}. {e}') + + def scroll_to_element(self, locator, locator_type='xpath'): + """Scroll to matching element""" + element = self.get_element(locator, locator_type) + if element: + self.actions.move_to_element(element).perform() + + def deselct_dropdown(self, locator, locator_type='xpath'): + """deselect all options from that SELECT on the page""" + select = Select(self.get_element(locator, locator_type)) + select.deselect_all() + + def dropdown_select(self, + locator, + locator_type, + by_index=False, + by_visible_text=False, + by_value=False): + + select = Select(self.get_element(locator, locator_type)) + if by_index: + select.select_by_index(by_index) + elif by_visible_text: + select.select_by_visible_text(by_visible_text) + elif by_value: + select.select_by_value(by_value) + + def wait_for_element(self, locator, + locator_type='xpath', + timeout=10, + poll_frequency=0.5): + """Wait to presence of an element""" + try: + by_type = self._get_by_type(locator_type) + + wait = WebDriverWait(self.driver, + timeout, + poll_frequency, + ignored_exceptions=[NoSuchElementException, + ElementNotVisibleException, + ElementNotSelectableException]) + + element = wait.until(EC.element_to_be_clickable((by_type, locator))) + self.log.info(f'Element found. Locator: {locator}, Loctor type: {locator_type}') + return element + + except TimeoutException: + self.log.info('time out exception') + + except InvalidArgumentException: + self.log.info(f'Element not found. Locator: {locator}, Loctor type: {locator_type}') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..efc0f39 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3' + +services: + app: + build: ./ + ports: + - "5000:5000" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b80c981 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +python-dotenv==0.15.0 +aniso8601==8.1.0 +astroid==2.4.2 +blinker==1.4 +certifi==2020.11.8 +chardet==3.0.4 +click==7.1.2 +flake8==3.8.4 +Flask==1.1.2 +Flask-HTTPAuth==4.2.0 +Flask-Mail==0.9.1 +Flask-RESTful==0.3.8 +Flask-SQLAlchemy==2.5.1 +Flask-Testing==0.8.1 +gunicorn==20.0.4 +idna==2.10 +importlib-metadata==3.7.3 +isort==5.6.4 +itsdangerous==1.1.0 +Jinja2==2.11.2 +lazy-object-proxy==1.4.3 +MarkupSafe==1.1.1 +mccabe==0.6.1 +pycodestyle==2.6.0 +pyflakes==2.2.0 +PyJWT==1.4.2 +pylint==2.6.0 +pyperclip==1.8.1 +python-dotenv==0.15.0 +pytz==2020.4 +requests==2.25.0 +selenium==3.141.0 +six==1.15.0 +SQLAlchemy==1.3.20 +toml==0.10.2 +typed-ast==1.4.1 +urllib3==1.26.2 +Werkzeug==1.0.1 +wrapt==1.12.1 +zipp==3.4.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..eb8d851 --- /dev/null +++ b/run.py @@ -0,0 +1,5 @@ +# ------- local imports ------- +from app.app import app + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5555)