diff --git a/.gitsecrets b/.gitsecrets
new file mode 100644
index 0000000..548e1b6
--- /dev/null
+++ b/.gitsecrets
@@ -0,0 +1 @@
+[0-9A-Za-z]{32}
diff --git a/dev/bootstrap.sh b/dev/bootstrap.sh
new file mode 100755
index 0000000..5ac0d73
--- /dev/null
+++ b/dev/bootstrap.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+
+# Inspired by RJ Zaworski's blog post at
+# https://rjzaworski.com/2018/01/keeping-git-hooks-in-sync
+
+set -e
+
+install_hooks() {
+ rm -rf ./.git/hooks
+ cp -r ./dev/hooks ./.git/hooks
+}
+
+log() {
+ echo "$1"
+}
+
+if [ ! -d ".git" ]; then {
+ log "Aborting because you are not at the root of a git repository"
+ exit 1
+}
+fi
+
+log "Configuring git hooks"
+install_hooks
diff --git a/dev/hooks/pre-commit b/dev/hooks/pre-commit
new file mode 100755
index 0000000..a17b960
--- /dev/null
+++ b/dev/hooks/pre-commit
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+
+# Pre-commit hook to run before each commit
+
+# Check that no secrets are about to be committed
+git secrets scan
diff --git a/requirements.txt b/requirements.txt
index dbd23d3..7f0528a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,12 +1,15 @@
alabaster==0.7.11
+atomicwrites==1.2.1
+attrs==18.2.0
Babel==2.6.0
blinker==1.4
certifi==2018.8.13
chardet==3.0.4
-click==6.7
+Click==7.0
colorama==0.3.9
+coverage==4.5.2
docutils==0.14
-Flask==0.12.2
+Flask==1.0.2
Flask-BabelEx==0.9.3
Flask-Login==0.4.1
Flask-Mail==0.9.1
@@ -22,13 +25,22 @@ itsdangerous==0.24
Jinja2==2.10
MarkupSafe==1.0
mccabe==0.6.1
+mock==2.0.0
+more-itertools==5.0.0
nose==1.3.7
packaging==17.1
passlib==1.7.1
+pbr==5.1.1
+pluggy==0.8.0
+py==1.7.0
Pygments==2.2.0
pyparsing==2.2.0
+pytest==4.1.0
+pytest-cov==2.6.1
+pytest-mock==1.10.0
python-dateutil==2.7.3
pytz==2018.3
+qpp-git-secrets==1.1.1
requests==2.20.1
six==1.11.0
snowballstemmer==1.2.1
@@ -36,6 +48,7 @@ speaklater==1.3
Sphinx==1.7.6
sphinx-rtd-theme==0.4.1
sphinxcontrib-websupport==1.1.0
+unidiff==0.5.5
urllib3==1.24.1
-Werkzeug==0.12.2
+Werkzeug==0.14.1
WTForms==2.1
diff --git a/test.sh b/test.sh
new file mode 100755
index 0000000..8380330
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+export CULTUREMESH_API_KEY=1234
+export WTF_CSRF_SECRET_KEY=1234
+export CULTUREMESH_API_BASE_ENDPOINT=https://www.culturemesh.com/api-dev/v1
+
+python -m pytest
\ No newline at end of file
diff --git a/test/unit/webapp/__init__.py b/test/unit/webapp/__init__.py
new file mode 100644
index 0000000..989ea25
--- /dev/null
+++ b/test/unit/webapp/__init__.py
@@ -0,0 +1,57 @@
+import pytest
+from culturemesh import app
+
+"""Initialize the testing environment
+
+Creates an app for testing that has the configuration flag ``TESTING`` set to
+``True``.
+
+"""
+
+# The following function is derived from an example in the Flask documentation
+# found at the following URL: http://flask.pocoo.org/docs/1.0/testing/. The
+# Flask license statement has been included below as attribution.
+#
+# Copyright (c) 2010 by the Pallets team.
+#
+# Some rights reserved.
+#
+# Redistribution and use in source and binary forms of the software as well as
+# documentation, with or without modification, are permitted provided that the
+# following conditions are met:
+#
+# * Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+# * 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.
+# * 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 AND DOCUMENTATION 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 AND DOCUMENTATION, EVEN IF
+# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+@pytest.fixture
+def client():
+ """Configures the app for testing
+
+ Sets app config variable ``TESTING`` to ``True``
+
+ :return: App for testing
+ """
+
+ #app.config['TESTING'] = True
+ client = app.test_client()
+
+ yield client
diff --git a/test/unit/webapp/test_posts.py b/test/unit/webapp/test_posts.py
new file mode 100644
index 0000000..817ad8b
--- /dev/null
+++ b/test/unit/webapp/test_posts.py
@@ -0,0 +1,114 @@
+from test.unit.webapp import client
+import mock
+from mock import call
+
+
+view_post_post = {'id': 626, 'id_network': 1, 'id_user': 157, 'img_link': None,
+ 'post_class': 'o',
+ 'post_date': 'Sun, 26 Aug 2018 22:31:04 GMT',
+ 'post_original': None,
+ 'post_text': "Hi everyone! I'm hoping to move here soon, but "
+ "I'd like to get a better sense of the local "
+ "community. Would anyone be willing to take a "
+ "few minutes to talk with me about there "
+ "experiences living here, particularly after "
+ "leaving home? Thanks!\n", 'vid_link': None}
+view_post_net = {'city_cur': 'Palo Alto', 'city_origin': None,
+ 'country_cur': 'United States',
+ 'country_origin': 'United States',
+ 'date_added': 'Tue, 12 Jan 2016 05:51:19 GMT', 'id': 1,
+ 'id_city_cur': 332851, 'id_city_origin': None,
+ 'id_country_cur': 47228, 'id_country_origin': 47228,
+ 'id_language_origin': None, 'id_region_cur': 55833,
+ 'id_region_origin': 56020, 'img_link': None,
+ 'language_origin': None, 'network_class': 'rc',
+ 'region_cur': 'California', 'region_origin': 'Michigan',
+ 'twitter_query_level': 'A'}
+view_post_replies = [{'id': 465, 'id_network': 1, 'id_parent': 626,
+ 'id_user': 157,
+ 'reply_date': 'Sun, 02 Dec 2018 18:20:40 GMT',
+ 'reply_text': "This is a test reply, but I'd be happy "
+ "to talk to you. "},
+ {'id': 461, 'id_network': 1, 'id_parent': 626,
+ 'id_user': 172,
+ 'reply_date': 'Tue, 18 Sep 2018 16:09:13 GMT',
+ 'reply_text': 'This is another test reply. Do not mind '
+ 'me, but welcome to Palo Alto! Hope you '
+ 'like it here'},
+ {'id': 460, 'id_network': 1, 'id_parent': 626,
+ 'id_user': 171,
+ 'reply_date': 'Tue, 18 Sep 2018 16:07:16 GMT',
+ 'reply_text': 'This is only a test reply. But I am sure '
+ 'someone else here can help you out.'}]
+
+
+def mock_client_get_user(id):
+ users = [
+ {'about_me': "I'm from Michigan",
+ 'act_code': '764efa883dda1e11db47671c4a3bbd9e',
+ 'company_news': None, 'confirmed': 0,
+ 'events_interested_in': None, 'events_upcoming': None,
+ 'first_name': 'c', 'fp_code': None, 'gender': 'n', 'id': 157,
+ 'img_link': 'https://www.culturemesh.com/user_images/null',
+ 'last_login': '0000-00-00 00:00:00', 'last_name': 's',
+ 'network_activity': None,
+ 'register_date': 'Sun, 02 Dec 2018 16:33:20 GMT', 'role': 0,
+ 'username': 'cs'},
+ {'about_me': 'I like to cook and watch movies. I recently made some '
+ 'clam chowder and it was amazing :D. Originally from '
+ 'Mexico, now living in the bay area.',
+ 'act_code': '', 'company_news': None, 'confirmed': 0,
+ 'events_interested_in': None, 'events_upcoming': None,
+ 'first_name': 'Alan', 'fp_code': None, 'gender': None, 'id': 171,
+ 'img_link': None, 'last_login': '0000-00-00 00:00:00',
+ 'last_name': 'Last name', 'network_activity': None,
+ 'register_date': 'Thu, 20 Sep 2018 10:30:04 GMT', 'role': 0,
+ 'username': 'aefl'},
+ {'about_me': 'Live and learn', 'act_code': '', 'company_news': None,
+ 'confirmed': 0, 'events_interested_in': None, 'events_upcoming': None,
+ 'first_name': 'Alan 2.0', 'fp_code': None, 'gender': None, 'id': 172,
+ 'img_link': None, 'last_login': '0000-00-00 00:00:00',
+ 'last_name': 'Lastname', 'network_activity': None,
+ 'register_date': 'Wed, 19 Sep 2018 22:15:15 GMT', 'role': 0,
+ 'username': 'aefl2'}
+ ]
+ for user in users:
+ if user['id'] == id:
+ return user
+ raise ValueError("User ID {} is unknown to mock_client_get_user".format(id))
+
+
+@mock.patch('culturemesh.blueprints.posts.controllers.Client.get_post',
+ return_value=view_post_post)
+@mock.patch('culturemesh.blueprints.posts.controllers.Client.get_network',
+ return_value=view_post_net)
+@mock.patch('culturemesh.blueprints.posts.controllers.Client.get_user',
+ side_effect=mock_client_get_user)
+@mock.patch('culturemesh.blueprints.posts.controllers.Client.get_post_replies',
+ return_value=view_post_replies)
+def test_view_post(replies, user, net, post, client):
+ result = client.get('/post/?id=626')
+ html = result.data.decode()
+
+ # Check that replies are displayed
+ assert "This is another test reply. Do not mind me, " in html
+ assert 'This is only a test reply.' in html
+
+ # Check that reply author username displayed
+ assert 'aefl' in html
+
+ # Check that post text displayed
+ assert 'Hi everyone!' in html
+
+ # Check that post author username displayed
+ assert 'cs' in html
+
+ # Check that network name displayed
+ assert 'From Michigan, United States in Palo Alto, California, ' \
+ 'United States' in html
+
+ replies.assert_called_with(626, 100)
+ user.assert_has_calls([call(157), call(171), call(172), call(157)],
+ any_order=False)
+ net.assert_called_with(1)
+ post.assert_called_with('626')
diff --git a/test/unit/webapp/test_root.py b/test/unit/webapp/test_root.py
new file mode 100644
index 0000000..f7ea2ce
--- /dev/null
+++ b/test/unit/webapp/test_root.py
@@ -0,0 +1,23 @@
+from test.unit.webapp import client
+
+
+def test_landing(client):
+ landing = client.get("/")
+ html = landing.data.decode()
+
+ # Check that links to `about` and `login` pages exist
+ assert "About" in html
+ assert " Login" in html
+
+ # Spot check important text
+ assert "At CultureMesh, we're building networks to match these " \
+ "real-world dynamics and knit the diverse fabrics of our world " \
+ "together." in html
+ assert "1. Join a network you belong to." in html
+
+ assert landing.status_code == 200
+
+
+def test_landing_aliases(client):
+ landing = client.get("/")
+ assert client.get("/index/").data == landing.data