From 341b8450b1a2bdfb8da260db224234aeb20adeda Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Wed, 31 Mar 2021 19:28:30 +0000 Subject: [PATCH] Initial commit --- .gitignore | 14 +++ .gitpod.yml | 13 +++ LICENSE.rst | 28 ++++++ MANIFEST.in | 6 ++ README.md | 2 - README.rst | 28 ++++++ flaskr/__init__.py | 50 +++++++++++ flaskr/auth.py | 116 ++++++++++++++++++++++++ flaskr/blog.py | 125 ++++++++++++++++++++++++++ flaskr/db.py | 54 +++++++++++ flaskr/schema.sql | 20 +++++ flaskr/static/style.css | 134 ++++++++++++++++++++++++++++ flaskr/templates/auth/login.html | 15 ++++ flaskr/templates/auth/register.html | 15 ++++ flaskr/templates/base.html | 24 +++++ flaskr/templates/blog/create.html | 15 ++++ flaskr/templates/blog/index.html | 28 ++++++ flaskr/templates/blog/update.html | 19 ++++ setup.cfg | 28 ++++++ setup.py | 3 + tests/conftest.py | 62 +++++++++++++ tests/data.sql | 8 ++ tests/test_auth.py | 69 ++++++++++++++ tests/test_blog.py | 83 +++++++++++++++++ tests/test_db.py | 29 ++++++ tests/test_factory.py | 12 +++ 26 files changed, 998 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .gitpod.yml create mode 100644 LICENSE.rst create mode 100644 MANIFEST.in delete mode 100644 README.md create mode 100644 README.rst create mode 100644 flaskr/__init__.py create mode 100644 flaskr/auth.py create mode 100644 flaskr/blog.py create mode 100644 flaskr/db.py create mode 100644 flaskr/schema.sql create mode 100644 flaskr/static/style.css create mode 100644 flaskr/templates/auth/login.html create mode 100644 flaskr/templates/auth/register.html create mode 100644 flaskr/templates/base.html create mode 100644 flaskr/templates/blog/create.html create mode 100644 flaskr/templates/blog/index.html create mode 100644 flaskr/templates/blog/update.html create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/data.sql create mode 100644 tests/test_auth.py create mode 100644 tests/test_blog.py create mode 100644 tests/test_db.py create mode 100644 tests/test_factory.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85a3584 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +venv/ +*.pyc +__pycache__/ +instance/ +.cache/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ +.idea/ +*.swp +*~ diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..1218c50 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,13 @@ +ports: + - port: 5000 + onOpen: open-preview + +tasks: + - before: | + export FLASK_APP=flaskr + export FLASK_ENV=development + init: | + pip install -e . + flask init-db + command: | + flask run diff --git a/LICENSE.rst b/LICENSE.rst new file mode 100644 index 0000000..9d227a0 --- /dev/null +++ b/LICENSE.rst @@ -0,0 +1,28 @@ +Copyright 2010 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..97d55d5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include LICENSE.rst +include flaskr/schema.sql +graft flaskr/static +graft flaskr/templates +graft tests +global-exclude *.pyc diff --git a/README.md b/README.md deleted file mode 100644 index 512bb78..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# python-flask-example -The official Flask example diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..245b941 --- /dev/null +++ b/README.rst @@ -0,0 +1,28 @@ +Flaskr +====== + +The basic blog app built in the Flask `tutorial`_. + +.. _tutorial: https://flask.palletsprojects.com/tutorial/ + + +Run +------- + +Open a workspace + https://gitpod.io/from-referrer/ + + +Test +---- + +:: + + $ pip install '.[test]' + $ pytest + +Run with coverage report:: + + $ coverage run -m pytest + $ coverage report + $ coverage html # open htmlcov/index.html in a browser diff --git a/flaskr/__init__.py b/flaskr/__init__.py new file mode 100644 index 0000000..bb9cce5 --- /dev/null +++ b/flaskr/__init__.py @@ -0,0 +1,50 @@ +import os + +from flask import Flask + + +def create_app(test_config=None): + """Create and configure an instance of the Flask application.""" + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + # a default secret that should be overridden by instance config + SECRET_KEY="dev", + # store the database in the instance folder + DATABASE=os.path.join(app.instance_path, "flaskr.sqlite"), + ) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile("config.py", silent=True) + else: + # load the test config if passed in + app.config.update(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + @app.route("/hello") + def hello(): + return "Hello, World!" + + # register the database commands + from flaskr import db + + db.init_app(app) + + # apply the blueprints to the app + from flaskr import auth, blog + + app.register_blueprint(auth.bp) + app.register_blueprint(blog.bp) + + # make url_for('index') == url_for('blog.index') + # in another app, you might define a separate main index here with + # app.route, while giving the blog blueprint a url_prefix, but for + # the tutorial the blog will be the main index + app.add_url_rule("/", endpoint="index") + + return app diff --git a/flaskr/auth.py b/flaskr/auth.py new file mode 100644 index 0000000..bcd3c67 --- /dev/null +++ b/flaskr/auth.py @@ -0,0 +1,116 @@ +import functools + +from flask import Blueprint +from flask import flash +from flask import g +from flask import redirect +from flask import render_template +from flask import request +from flask import session +from flask import url_for +from werkzeug.security import check_password_hash +from werkzeug.security import generate_password_hash + +from flaskr.db import get_db + +bp = Blueprint("auth", __name__, url_prefix="/auth") + + +def login_required(view): + """View decorator that redirects anonymous users to the login page.""" + + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for("auth.login")) + + return view(**kwargs) + + return wrapped_view + + +@bp.before_app_request +def load_logged_in_user(): + """If a user id is stored in the session, load the user object from + the database into ``g.user``.""" + user_id = session.get("user_id") + + if user_id is None: + g.user = None + else: + g.user = ( + get_db().execute("SELECT * FROM user WHERE id = ?", (user_id,)).fetchone() + ) + + +@bp.route("/register", methods=("GET", "POST")) +def register(): + """Register a new user. + + Validates that the username is not already taken. Hashes the + password for security. + """ + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] + db = get_db() + error = None + + if not username: + error = "Username is required." + elif not password: + error = "Password is required." + elif ( + db.execute("SELECT id FROM user WHERE username = ?", (username,)).fetchone() + is not None + ): + error = f"User {username} is already registered." + + if error is None: + # the name is available, store it in the database and go to + # the login page + db.execute( + "INSERT INTO user (username, password) VALUES (?, ?)", + (username, generate_password_hash(password)), + ) + db.commit() + return redirect(url_for("auth.login")) + + flash(error) + + return render_template("auth/register.html") + + +@bp.route("/login", methods=("GET", "POST")) +def login(): + """Log in a registered user by adding the user id to the session.""" + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] + db = get_db() + error = None + user = db.execute( + "SELECT * FROM user WHERE username = ?", (username,) + ).fetchone() + + if user is None: + error = "Incorrect username." + elif not check_password_hash(user["password"], password): + error = "Incorrect password." + + if error is None: + # store the user id in a new session and return to the index + session.clear() + session["user_id"] = user["id"] + return redirect(url_for("index")) + + flash(error) + + return render_template("auth/login.html") + + +@bp.route("/logout") +def logout(): + """Clear the current session, including the stored user id.""" + session.clear() + return redirect(url_for("index")) diff --git a/flaskr/blog.py b/flaskr/blog.py new file mode 100644 index 0000000..3704626 --- /dev/null +++ b/flaskr/blog.py @@ -0,0 +1,125 @@ +from flask import Blueprint +from flask import flash +from flask import g +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from werkzeug.exceptions import abort + +from flaskr.auth import login_required +from flaskr.db import get_db + +bp = Blueprint("blog", __name__) + + +@bp.route("/") +def index(): + """Show all the posts, most recent first.""" + db = get_db() + posts = db.execute( + "SELECT p.id, title, body, created, author_id, username" + " FROM post p JOIN user u ON p.author_id = u.id" + " ORDER BY created DESC" + ).fetchall() + return render_template("blog/index.html", posts=posts) + + +def get_post(id, check_author=True): + """Get a post and its author by id. + + Checks that the id exists and optionally that the current user is + the author. + + :param id: id of post to get + :param check_author: require the current user to be the author + :return: the post with author information + :raise 404: if a post with the given id doesn't exist + :raise 403: if the current user isn't the author + """ + post = ( + get_db() + .execute( + "SELECT p.id, title, body, created, author_id, username" + " FROM post p JOIN user u ON p.author_id = u.id" + " WHERE p.id = ?", + (id,), + ) + .fetchone() + ) + + if post is None: + abort(404, f"Post id {id} doesn't exist.") + + if check_author and post["author_id"] != g.user["id"]: + abort(403) + + return post + + +@bp.route("/create", methods=("GET", "POST")) +@login_required +def create(): + """Create a new post for the current user.""" + if request.method == "POST": + title = request.form["title"] + body = request.form["body"] + error = None + + if not title: + error = "Title is required." + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + "INSERT INTO post (title, body, author_id) VALUES (?, ?, ?)", + (title, body, g.user["id"]), + ) + db.commit() + return redirect(url_for("blog.index")) + + return render_template("blog/create.html") + + +@bp.route("//update", methods=("GET", "POST")) +@login_required +def update(id): + """Update a post if the current user is the author.""" + post = get_post(id) + + if request.method == "POST": + title = request.form["title"] + body = request.form["body"] + error = None + + if not title: + error = "Title is required." + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + "UPDATE post SET title = ?, body = ? WHERE id = ?", (title, body, id) + ) + db.commit() + return redirect(url_for("blog.index")) + + return render_template("blog/update.html", post=post) + + +@bp.route("//delete", methods=("POST",)) +@login_required +def delete(id): + """Delete a post. + + Ensures that the post exists and that the logged in user is the + author of the post. + """ + get_post(id) + db = get_db() + db.execute("DELETE FROM post WHERE id = ?", (id,)) + db.commit() + return redirect(url_for("blog.index")) diff --git a/flaskr/db.py b/flaskr/db.py new file mode 100644 index 0000000..f1e2dc3 --- /dev/null +++ b/flaskr/db.py @@ -0,0 +1,54 @@ +import sqlite3 + +import click +from flask import current_app +from flask import g +from flask.cli import with_appcontext + + +def get_db(): + """Connect to the application's configured database. The connection + is unique for each request and will be reused if this is called + again. + """ + if "db" not in g: + g.db = sqlite3.connect( + current_app.config["DATABASE"], detect_types=sqlite3.PARSE_DECLTYPES + ) + g.db.row_factory = sqlite3.Row + + return g.db + + +def close_db(e=None): + """If this request connected to the database, close the + connection. + """ + db = g.pop("db", None) + + if db is not None: + db.close() + + +def init_db(): + """Clear existing data and create new tables.""" + db = get_db() + + with current_app.open_resource("schema.sql") as f: + db.executescript(f.read().decode("utf8")) + + +@click.command("init-db") +@with_appcontext +def init_db_command(): + """Clear existing data and create new tables.""" + init_db() + click.echo("Initialized the database.") + + +def init_app(app): + """Register database functions with the Flask app. This is called by + the application factory. + """ + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) diff --git a/flaskr/schema.sql b/flaskr/schema.sql new file mode 100644 index 0000000..dd4c866 --- /dev/null +++ b/flaskr/schema.sql @@ -0,0 +1,20 @@ +-- Initialize the database. +-- Drop any existing data and create empty tables. + +DROP TABLE IF EXISTS user; +DROP TABLE IF EXISTS post; + +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL +); + +CREATE TABLE post ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + body TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES user (id) +); diff --git a/flaskr/static/style.css b/flaskr/static/style.css new file mode 100644 index 0000000..2f1f4d0 --- /dev/null +++ b/flaskr/static/style.css @@ -0,0 +1,134 @@ +html { + font-family: sans-serif; + background: #eee; + padding: 1rem; +} + +body { + max-width: 960px; + margin: 0 auto; + background: white; +} + +h1, h2, h3, h4, h5, h6 { + font-family: serif; + color: #377ba8; + margin: 1rem 0; +} + +a { + color: #377ba8; +} + +hr { + border: none; + border-top: 1px solid lightgray; +} + +nav { + background: lightgray; + display: flex; + align-items: center; + padding: 0 0.5rem; +} + +nav h1 { + flex: auto; + margin: 0; +} + +nav h1 a { + text-decoration: none; + padding: 0.25rem 0.5rem; +} + +nav ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; +} + +nav ul li a, nav ul li span, header .action { + display: block; + padding: 0.5rem; +} + +.content { + padding: 0 1rem 1rem; +} + +.content > header { + border-bottom: 1px solid lightgray; + display: flex; + align-items: flex-end; +} + +.content > header h1 { + flex: auto; + margin: 1rem 0 0.25rem 0; +} + +.flash { + margin: 1em 0; + padding: 1em; + background: #cae6f6; + border: 1px solid #377ba8; +} + +.post > header { + display: flex; + align-items: flex-end; + font-size: 0.85em; +} + +.post > header > div:first-of-type { + flex: auto; +} + +.post > header h1 { + font-size: 1.5em; + margin-bottom: 0; +} + +.post .about { + color: slategray; + font-style: italic; +} + +.post .body { + white-space: pre-line; +} + +.content:last-child { + margin-bottom: 0; +} + +.content form { + margin: 1em 0; + display: flex; + flex-direction: column; +} + +.content label { + font-weight: bold; + margin-bottom: 0.5em; +} + +.content input, .content textarea { + margin-bottom: 1em; +} + +.content textarea { + min-height: 12em; + resize: vertical; +} + +input.danger { + color: #cc2f2e; +} + +input[type=submit] { + align-self: start; + min-width: 10em; +} diff --git a/flaskr/templates/auth/login.html b/flaskr/templates/auth/login.html new file mode 100644 index 0000000..b326b5a --- /dev/null +++ b/flaskr/templates/auth/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Log In{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/flaskr/templates/auth/register.html b/flaskr/templates/auth/register.html new file mode 100644 index 0000000..4320e17 --- /dev/null +++ b/flaskr/templates/auth/register.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Register{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/flaskr/templates/base.html b/flaskr/templates/base.html new file mode 100644 index 0000000..f09e926 --- /dev/null +++ b/flaskr/templates/base.html @@ -0,0 +1,24 @@ + +{% block title %}{% endblock %} - Flaskr + + +
+
+ {% block header %}{% endblock %} +
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block content %}{% endblock %} +
diff --git a/flaskr/templates/blog/create.html b/flaskr/templates/blog/create.html new file mode 100644 index 0000000..88e31e4 --- /dev/null +++ b/flaskr/templates/blog/create.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}New Post{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/flaskr/templates/blog/index.html b/flaskr/templates/blog/index.html new file mode 100644 index 0000000..3481b8e --- /dev/null +++ b/flaskr/templates/blog/index.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Posts{% endblock %}

+ {% if g.user %} + New + {% endif %} +{% endblock %} + +{% block content %} + {% for post in posts %} +
+
+
+

{{ post['title'] }}

+
by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}
+
+ {% if g.user['id'] == post['author_id'] %} + Edit + {% endif %} +
+

{{ post['body'] }}

+
+ {% if not loop.last %} +
+ {% endif %} + {% endfor %} +{% endblock %} diff --git a/flaskr/templates/blog/update.html b/flaskr/templates/blog/update.html new file mode 100644 index 0000000..2c405e6 --- /dev/null +++ b/flaskr/templates/blog/update.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Edit "{{ post['title'] }}"{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+
+
+ +
+{% endblock %} diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d001093 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,28 @@ +[metadata] +name = flaskr +version = 1.0.0 +url = https://flask.palletsprojects.com/tutorial/ +license = BSD-3-Clause +maintainer = Pallets +maintainer_email = contact@palletsprojects.com +description = The basic blog app built in the Flask tutorial. +long_description = file: README.rst +long_description_content_type = text/x-rst + +[options] +packages = find: +include_package_data = true +install_requires = + Flask + +[options.extras_require] +test = + pytest + +[tool:pytest] +testpaths = tests + +[coverage:run] +branch = True +source = + flaskr diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6068493 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6bf62f0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,62 @@ +import os +import tempfile + +import pytest + +from flaskr import create_app +from flaskr.db import get_db +from flaskr.db import init_db + +# read in SQL for populating test data +with open(os.path.join(os.path.dirname(__file__), "data.sql"), "rb") as f: + _data_sql = f.read().decode("utf8") + + +@pytest.fixture +def app(): + """Create and configure a new app instance for each test.""" + # create a temporary file to isolate the database for each test + db_fd, db_path = tempfile.mkstemp() + # create the app with common test config + app = create_app({"TESTING": True, "DATABASE": db_path}) + + # create the database and load test data + with app.app_context(): + init_db() + get_db().executescript(_data_sql) + + yield app + + # close and remove the temporary database + os.close(db_fd) + os.unlink(db_path) + + +@pytest.fixture +def client(app): + """A test client for the app.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """A test runner for the app's Click commands.""" + return app.test_cli_runner() + + +class AuthActions: + def __init__(self, client): + self._client = client + + def login(self, username="test", password="test"): + return self._client.post( + "/auth/login", data={"username": username, "password": password} + ) + + def logout(self): + return self._client.get("/auth/logout") + + +@pytest.fixture +def auth(client): + return AuthActions(client) diff --git a/tests/data.sql b/tests/data.sql new file mode 100644 index 0000000..9b68006 --- /dev/null +++ b/tests/data.sql @@ -0,0 +1,8 @@ +INSERT INTO user (username, password) +VALUES + ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'), + ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79'); + +INSERT INTO post (title, body, author_id, created) +VALUES + ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00'); diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..3ac9a12 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,69 @@ +import pytest +from flask import g +from flask import session + +from flaskr.db import get_db + + +def test_register(client, app): + # test that viewing the page renders without template errors + assert client.get("/auth/register").status_code == 200 + + # test that successful registration redirects to the login page + response = client.post("/auth/register", data={"username": "a", "password": "a"}) + assert "http://localhost/auth/login" == response.headers["Location"] + + # test that the user was inserted into the database + with app.app_context(): + assert ( + get_db().execute("select * from user where username = 'a'").fetchone() + is not None + ) + + +@pytest.mark.parametrize( + ("username", "password", "message"), + ( + ("", "", b"Username is required."), + ("a", "", b"Password is required."), + ("test", "test", b"already registered"), + ), +) +def test_register_validate_input(client, username, password, message): + response = client.post( + "/auth/register", data={"username": username, "password": password} + ) + assert message in response.data + + +def test_login(client, auth): + # test that viewing the page renders without template errors + assert client.get("/auth/login").status_code == 200 + + # test that successful login redirects to the index page + response = auth.login() + assert response.headers["Location"] == "http://localhost/" + + # login request set the user_id in the session + # check that the user is loaded from the session + with client: + client.get("/") + assert session["user_id"] == 1 + assert g.user["username"] == "test" + + +@pytest.mark.parametrize( + ("username", "password", "message"), + (("a", "test", b"Incorrect username."), ("test", "a", b"Incorrect password.")), +) +def test_login_validate_input(auth, username, password, message): + response = auth.login(username, password) + assert message in response.data + + +def test_logout(client, auth): + auth.login() + + with client: + auth.logout() + assert "user_id" not in session diff --git a/tests/test_blog.py b/tests/test_blog.py new file mode 100644 index 0000000..9185968 --- /dev/null +++ b/tests/test_blog.py @@ -0,0 +1,83 @@ +import pytest + +from flaskr.db import get_db + + +def test_index(client, auth): + response = client.get("/") + assert b"Log In" in response.data + assert b"Register" in response.data + + auth.login() + response = client.get("/") + assert b"test title" in response.data + assert b"by test on 2018-01-01" in response.data + assert b"test\nbody" in response.data + assert b'href="/1/update"' in response.data + + +@pytest.mark.parametrize("path", ("/create", "/1/update", "/1/delete")) +def test_login_required(client, path): + response = client.post(path) + assert response.headers["Location"] == "http://localhost/auth/login" + + +def test_author_required(app, client, auth): + # change the post author to another user + with app.app_context(): + db = get_db() + db.execute("UPDATE post SET author_id = 2 WHERE id = 1") + db.commit() + + auth.login() + # current user can't modify other user's post + assert client.post("/1/update").status_code == 403 + assert client.post("/1/delete").status_code == 403 + # current user doesn't see edit link + assert b'href="/1/update"' not in client.get("/").data + + +@pytest.mark.parametrize("path", ("/2/update", "/2/delete")) +def test_exists_required(client, auth, path): + auth.login() + assert client.post(path).status_code == 404 + + +def test_create(client, auth, app): + auth.login() + assert client.get("/create").status_code == 200 + client.post("/create", data={"title": "created", "body": ""}) + + with app.app_context(): + db = get_db() + count = db.execute("SELECT COUNT(id) FROM post").fetchone()[0] + assert count == 2 + + +def test_update(client, auth, app): + auth.login() + assert client.get("/1/update").status_code == 200 + client.post("/1/update", data={"title": "updated", "body": ""}) + + with app.app_context(): + db = get_db() + post = db.execute("SELECT * FROM post WHERE id = 1").fetchone() + assert post["title"] == "updated" + + +@pytest.mark.parametrize("path", ("/create", "/1/update")) +def test_create_update_validate(client, auth, path): + auth.login() + response = client.post(path, data={"title": "", "body": ""}) + assert b"Title is required." in response.data + + +def test_delete(client, auth, app): + auth.login() + response = client.post("/1/delete") + assert response.headers["Location"] == "http://localhost/" + + with app.app_context(): + db = get_db() + post = db.execute("SELECT * FROM post WHERE id = 1").fetchone() + assert post is None diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..2363bf8 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,29 @@ +import sqlite3 + +import pytest + +from flaskr.db import get_db + + +def test_get_close_db(app): + with app.app_context(): + db = get_db() + assert db is get_db() + + with pytest.raises(sqlite3.ProgrammingError) as e: + db.execute("SELECT 1") + + assert "closed" in str(e.value) + + +def test_init_db_command(runner, monkeypatch): + class Recorder: + called = False + + def fake_init_db(): + Recorder.called = True + + monkeypatch.setattr("flaskr.db.init_db", fake_init_db) + result = runner.invoke(args=["init-db"]) + assert "Initialized" in result.output + assert Recorder.called diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..9b7ca57 --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,12 @@ +from flaskr import create_app + + +def test_config(): + """Test create_app without passing test config.""" + assert not create_app().testing + assert create_app({"TESTING": True}).testing + + +def test_hello(client): + response = client.get("/hello") + assert response.data == b"Hello, World!"