# -*- coding: utf-8 -*-
from __future__ import unicode_literals
try:
import unittest2 as unittest
except ImportError:
import unittest # NOQA
try:
from unittest.mock import patch
except ImportError:
from mock import patch
import datetime
import os
import json
from collections import defaultdict
import six
from time import sleep
from werkzeug.security import generate_password_hash, check_password_hash
from flask import session
from flask_testing import TestCase
from ihatemoney.run import create_app, db, load_configuration
from ihatemoney.manage import (
GenerateConfig, GeneratePasswordHash, DeleteProject)
from ihatemoney import models
from ihatemoney import utils
# Unset configuration file env var if previously set
os.environ.pop('IHATEMONEY_SETTINGS_FILE_PATH', None)
__HERE__ = os.path.dirname(os.path.abspath(__file__))
class BaseTestCase(TestCase):
SECRET_KEY = "TEST SESSION"
def create_app(self):
# Pass the test object as a configuration.
return create_app(self)
def setUp(self):
db.create_all()
def tearDown(self):
# clean after testing
db.session.remove()
db.drop_all()
def login(self, project, password=None, test_client=None):
password = password or project
return self.client.post('/authenticate', data=dict(
id=project, password=password), follow_redirects=True)
def post_project(self, name):
"""Create a fake project"""
# create the project
self.client.post("/create", data={
'name': name,
'id': name,
'password': name,
'contact_email': '%s@notmyidea.org' % name
})
def create_project(self, name):
project = models.Project(
id=name,
name=six.text_type(name),
password=generate_password_hash(name),
contact_email="%s@notmyidea.org" % name)
models.db.session.add(project)
models.db.session.commit()
class IhatemoneyTestCase(BaseTestCase):
SQLALCHEMY_DATABASE_URI = "sqlite://"
TESTING = True
WTF_CSRF_ENABLED = False # Simplifies the tests.
def assertStatus(self, expected, resp, url=""):
return self.assertEqual(expected, resp.status_code,
"%s expected %s, got %s" % (url, expected, resp.status_code))
class ConfigurationTestCase(BaseTestCase):
def test_default_configuration(self):
"""Test that default settings are loaded when no other configuration file is specified"""
self.assertFalse(self.app.config['DEBUG'])
self.assertEqual(self.app.config['SQLALCHEMY_DATABASE_URI'], 'sqlite:////tmp/ihatemoney.db')
self.assertFalse(self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'])
self.assertEqual(self.app.config['MAIL_DEFAULT_SENDER'],
("Budget manager", "budget@notmyidea.org"))
def test_env_var_configuration_file(self):
"""Test that settings are loaded from the specified configuration file"""
os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] = os.path.join(__HERE__,
"ihatemoney_envvar.cfg")
load_configuration(self.app)
self.assertEqual(self.app.config['SECRET_KEY'], 'lalatra')
# Test that the specified configuration file is loaded
# even if the default configuration file ihatemoney.cfg exists
os.environ['IHATEMONEY_SETTINGS_FILE_PATH'] = os.path.join(__HERE__,
"ihatemoney_envvar.cfg")
self.app.config.root_path = __HERE__
load_configuration(self.app)
self.assertEqual(self.app.config['SECRET_KEY'], 'lalatra')
os.environ.pop('IHATEMONEY_SETTINGS_FILE_PATH', None)
def test_default_configuration_file(self):
"""Test that settings are loaded from the default configuration file"""
self.app.config.root_path = __HERE__
load_configuration(self.app)
self.assertEqual(self.app.config['SECRET_KEY'], 'supersecret')
class BudgetTestCase(IhatemoneyTestCase):
def test_notifications(self):
"""Test that the notifications are sent, and that email adresses
are checked properly.
"""
# sending a message to one person
with self.app.mail.record_messages() as outbox:
# create a project
self.login("raclette")
self.post_project("raclette")
self.client.post("/raclette/invite",
data={"emails": 'alexis@notmyidea.org'})
self.assertEqual(len(outbox), 2)
self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"])
self.assertEqual(outbox[1].recipients, ["alexis@notmyidea.org"])
# sending a message to multiple persons
with self.app.mail.record_messages() as outbox:
self.client.post("/raclette/invite",
data={"emails": 'alexis@notmyidea.org, toto@notmyidea.org'})
# only one message is sent to multiple persons
self.assertEqual(len(outbox), 1)
self.assertEqual(outbox[0].recipients,
["alexis@notmyidea.org", "toto@notmyidea.org"])
# mail address checking
with self.app.mail.record_messages() as outbox:
response = self.client.post("/raclette/invite",
data={"emails": "toto"})
self.assertEqual(len(outbox), 0) # no message sent
self.assertIn("The email toto is not valid", response.data.decode('utf-8'))
# mixing good and wrong adresses shouldn't send any messages
with self.app.mail.record_messages() as outbox:
self.client.post("/raclette/invite",
data={"emails": 'alexis@notmyidea.org, alexis'}) # not valid
# only one message is sent to multiple persons
self.assertEqual(len(outbox), 0)
def test_invite(self):
"""Test that invitation e-mails are sent properly
"""
self.login("raclette")
self.post_project("raclette")
with self.app.mail.record_messages() as outbox:
self.client.post("/raclette/invite",
data={"emails": 'toto@notmyidea.org'})
self.assertEqual(len(outbox), 1)
url_start = outbox[0].body.find('You can log in using this link: ') + 32
url_end = outbox[0].body.find('.\n', url_start)
url = outbox[0].body[url_start:url_end]
self.client.get("/exit")
# Test that we got a valid token
resp = self.client.get(url, follow_redirects=True)
self.assertIn(
'You probably want to ", resp.data.decode('utf-8'))
# Test that password can be changed
self.client.post(url, data={'password': 'pass', 'password_confirmation': 'pass'})
resp = self.login('raclette', password='pass')
self.assertIn("Account manager - raclette", resp.data.decode('utf-8'))
# Test empty and null tokens
resp = self.client.get("/reset-password")
self.assertIn("No token provided", resp.data.decode('utf-8'))
resp = self.client.get("/reset-password?token=token")
self.assertIn("Invalid token", resp.data.decode('utf-8'))
def test_project_creation(self):
with self.app.test_client() as c:
# add a valid project
c.post("/create", data={
'name': 'The fabulous raclette party',
'id': 'raclette',
'password': 'party',
'contact_email': 'raclette@notmyidea.org'
})
# session is updated
self.assertTrue(session['raclette'])
# project is created
self.assertEqual(len(models.Project.query.all()), 1)
# Add a second project with the same id
models.Project.query.get('raclette')
c.post("/create", data={
'name': 'Another raclette party',
'id': 'raclette', # already used !
'password': 'party',
'contact_email': 'raclette@notmyidea.org'
})
# no new project added
self.assertEqual(len(models.Project.query.all()), 1)
def test_project_deletion(self):
with self.app.test_client() as c:
c.post("/create", data={
'name': 'raclette party',
'id': 'raclette',
'password': 'party',
'contact_email': 'raclette@notmyidea.org'
})
# project added
self.assertEqual(len(models.Project.query.all()), 1)
c.get('/raclette/delete')
# project removed
self.assertEqual(len(models.Project.query.all()), 0)
def test_bill_placeholder(self):
self.post_project("raclette")
self.login("raclette")
result = self.client.get("/raclette/")
# Empty bill list and no members, should now propose to add members first
self.assertIn(
'You probably want to ', resp.data.decode('utf-8'))
def test_authentication(self):
# try to authenticate without credentials should redirect
# to the authentication page
resp = self.client.post("/authenticate")
self.assertIn("Authentication", resp.data.decode('utf-8'))
# raclette that the login / logout process works
self.create_project("raclette")
# try to see the project while not being authenticated should redirect
# to the authentication page
resp = self.client.get("/raclette", follow_redirects=True)
self.assertIn("Authentication", resp.data.decode('utf-8'))
# try to connect with wrong credentials should not work
with self.app.test_client() as c:
resp = c.post("/authenticate",
data={'id': 'raclette', 'password': 'nope'})
self.assertIn("Authentication", resp.data.decode('utf-8'))
self.assertNotIn('raclette', session)
# try to connect with the right credentials should work
with self.app.test_client() as c:
resp = c.post("/authenticate",
data={'id': 'raclette', 'password': 'raclette'})
self.assertNotIn("Authentication", resp.data.decode('utf-8'))
self.assertIn('raclette', session)
self.assertTrue(session['raclette'])
# logout should wipe the session out
c.get("/exit")
self.assertNotIn('raclette', session)
# test that with admin credentials, one can access every project
self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass")
with self.app.test_client() as c:
resp = c.post("/admin?goto=%2Fraclette", data={'admin_password': 'pass'})
self.assertNotIn("Authentication", resp.data.decode('utf-8'))
self.assertTrue(session['is_admin'])
def test_admin_authentication(self):
self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass")
# Disable public project creation so we have an admin endpoint to test
self.app.config['ALLOW_PUBLIC_PROJECT_CREATION'] = False
# test the redirection to the authentication page when trying to access admin endpoints
resp = self.client.get("/create")
self.assertIn('', resp.data.decode('utf-8'))
# test right password
resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'pass'})
self.assertIn('/create', resp.data.decode('utf-8'))
# test wrong password
resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
self.assertNotIn('/create', resp.data.decode('utf-8'))
# test empty password
resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': ''})
self.assertNotIn('/create', resp.data.decode('utf-8'))
def test_login_throttler(self):
self.app.config['ADMIN_PASSWORD'] = generate_password_hash("pass")
# Activate admin login throttling by authenticating 4 times with a wrong passsword
self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
self.assertIn('Too many failed login attempts, please retry later.',
resp.data.decode('utf-8'))
# Change throttling delay
import gc
for obj in gc.get_objects():
if isinstance(obj, utils.LoginThrottler):
obj._delay = 0.005
break
# Wait for delay to expire and retry logging in
sleep(1)
resp = self.client.post("/admin?goto=%2Fcreate", data={'admin_password': 'wrong'})
self.assertNotIn('Too many failed login attempts, please retry later.',
resp.data.decode('utf-8'))
def test_manage_bills(self):
self.post_project("raclette")
# add two persons
self.client.post("/raclette/members/add", data={'name': 'alexis'})
self.client.post("/raclette/members/add", data={'name': 'fred'})
members_ids = [m.id for m in
models.Project.query.get("raclette").members]
# create a bill
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': members_ids[0],
'payed_for': members_ids,
'amount': '25',
})
models.Project.query.get("raclette")
bill = models.Bill.query.one()
self.assertEqual(bill.amount, 25)
# edit the bill
self.client.post("/raclette/edit/%s" % bill.id, data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': members_ids[0],
'payed_for': members_ids,
'amount': '10',
})
bill = models.Bill.query.one()
self.assertEqual(bill.amount, 10, "bill edition")
# delete the bill
self.client.get("/raclette/delete/%s" % bill.id)
self.assertEqual(0, len(models.Bill.query.all()), "bill deletion")
# test balance
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': members_ids[0],
'payed_for': members_ids,
'amount': '19',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': members_ids[1],
'payed_for': members_ids[0],
'amount': '20',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': members_ids[1],
'payed_for': members_ids,
'amount': '17',
})
balance = models.Project.query.get("raclette").balance
self.assertEqual(set(balance.values()), set([19.0, -19.0]))
# Bill with negative amount
self.client.post("/raclette/add", data={
'date': '2011-08-12',
'what': 'fromage à raclette',
'payer': members_ids[0],
'payed_for': members_ids,
'amount': '-25'
})
bill = models.Bill.query.filter(models.Bill.date == '2011-08-12')[0]
self.assertEqual(bill.amount, -25)
# add a bill with a comma
self.client.post("/raclette/add", data={
'date': '2011-08-01',
'what': 'fromage à raclette',
'payer': members_ids[0],
'payed_for': members_ids,
'amount': '25,02',
})
bill = models.Bill.query.filter(models.Bill.date == '2011-08-01')[0]
self.assertEqual(bill.amount, 25.02)
def test_weighted_balance(self):
self.post_project("raclette")
# add two persons
self.client.post("/raclette/members/add", data={'name': 'alexis'})
self.client.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4})
members_ids = [m.id for m in
models.Project.query.get("raclette").members]
# test balance
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': members_ids[0],
'payed_for': members_ids,
'amount': '10',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'pommes de terre',
'payer': members_ids[1],
'payed_for': members_ids,
'amount': '10',
})
balance = models.Project.query.get("raclette").balance
self.assertEqual(set(balance.values()), set([6, -6]))
def test_weighted_members_list(self):
self.post_project("raclette")
# add two persons
self.client.post("/raclette/members/add", data={'name': 'alexis'})
self.client.post("/raclette/members/add", data={'name': 'tata', 'weight': 1})
resp = self.client.get("/raclette/")
self.assertIn('extra-info', resp.data.decode('utf-8'))
self.client.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4})
resp = self.client.get("/raclette/")
self.assertNotIn('extra-info', resp.data.decode('utf-8'))
def test_negative_weight(self):
self.post_project("raclette")
# Add one user and edit it to have a negative share
self.client.post("/raclette/members/add", data={'name': 'alexis'})
resp = self.client.post("/raclette/members/1/edit", data={'name': 'alexis', 'weight': -1})
# An error should be generated, and its weight should still be 1.
self.assertIn('
', resp.data.decode('utf-8'))
self.assertEqual(len(models.Project.query.get('raclette').members), 1)
self.assertEqual(models.Project.query.get('raclette').members[0].weight, 1)
def test_rounding(self):
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={'name': 'alexis'})
self.client.post("/raclette/members/add", data={'name': 'fred'})
self.client.post("/raclette/members/add", data={'name': 'tata'})
# create bills
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': 1,
'payed_for': [1, 2, 3],
'amount': '24.36',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'red wine',
'payer': 2,
'payed_for': [1],
'amount': '19.12',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'delicatessen',
'payer': 1,
'payed_for': [1, 2],
'amount': '22',
})
balance = models.Project.query.get("raclette").balance
result = {}
result[models.Project.query.get("raclette").members[0].id] = 8.12
result[models.Project.query.get("raclette").members[1].id] = 0.0
result[models.Project.query.get("raclette").members[2].id] = -8.12
# Since we're using floating point to store currency, we can have some
# rounding issues that prevent test from working.
# However, we should obtain the same values as the theorical ones if we
# round to 2 decimals, like in the UI.
for key, value in six.iteritems(balance):
self.assertEqual(round(value, 2), result[key])
def test_edit_project(self):
# A project should be editable
self.post_project("raclette")
new_data = {
'name': 'Super raclette party!',
'contact_email': 'alexis@notmyidea.org',
'password': 'didoudida'
}
resp = self.client.post("/raclette/edit", data=new_data,
follow_redirects=True)
self.assertEqual(resp.status_code, 200)
project = models.Project.query.get("raclette")
self.assertEqual(project.name, new_data['name'])
self.assertEqual(project.contact_email, new_data['contact_email'])
self.assertTrue(check_password_hash(project.password, new_data['password']))
# Editing a project with a wrong email address should fail
new_data['contact_email'] = 'wrong_email'
resp = self.client.post("/raclette/edit", data=new_data,
follow_redirects=True)
self.assertIn("Invalid email address", resp.data.decode('utf-8'))
def test_dashboard(self):
# test that the dashboard is deactivated by default
resp = self.client.post(
"/admin?goto=%2Fdashboard",
data={'admin_password': 'adminpass'},
follow_redirects=True
)
self.assertIn('
', resp.data.decode('utf-8'))
# test access to the dashboard when it is activated
self.app.config['ACTIVATE_ADMIN_DASHBOARD'] = True
self.app.config['ADMIN_PASSWORD'] = generate_password_hash("adminpass")
resp = self.client.post(
"/admin?goto=%2Fdashboard",
data={'admin_password': 'adminpass'},
follow_redirects=True
)
self.assertIn('| Project | Number of members', resp.data.decode('utf-8'))
def test_statistics_page(self):
self.post_project("raclette")
response = self.client.get("/raclette/statistics")
self.assertEqual(response.status_code, 200)
def test_statistics(self):
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={'name': 'alexis', 'weight': 2})
self.client.post("/raclette/members/add", data={'name': 'fred'})
self.client.post("/raclette/members/add", data={'name': 'tata'})
# Add a member with a balance=0 :
self.client.post("/raclette/members/add", data={'name': 'toto'})
# create bills
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': 1,
'payed_for': [1, 2, 3],
'amount': '10.0',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'red wine',
'payer': 2,
'payed_for': [1],
'amount': '20',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'delicatessen',
'payer': 1,
'payed_for': [1, 2],
'amount': '10',
})
response = self.client.get("/raclette/statistics")
self.assertIn(" | alexis | \n "
+ "20.00 | \n "
+ "31.67 | \n "
+ "-11.67 | \n",
response.data.decode('utf-8'))
self.assertIn("fred | \n "
+ "20.00 | \n "
+ "5.83 | \n "
+ "14.17 | \n",
response.data.decode('utf-8'))
self.assertIn("tata | \n "
+ "0.00 | \n "
+ "2.50 | \n "
+ "-2.50 | \n",
response.data.decode('utf-8'))
self.assertIn("toto | \n "
+ "0.00 | \n "
+ "0.00 | \n "
+ "0.00 | \n",
response.data.decode('utf-8'))
def test_settle_page(self):
self.post_project("raclette")
response = self.client.get("/raclette/settle_bills")
self.assertEqual(response.status_code, 200)
def test_settle(self):
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={'name': 'alexis'})
self.client.post("/raclette/members/add", data={'name': 'fred'})
self.client.post("/raclette/members/add", data={'name': 'tata'})
# Add a member with a balance=0 :
self.client.post("/raclette/members/add", data={'name': 'toto'})
# create bills
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'fromage à raclette',
'payer': 1,
'payed_for': [1, 2, 3],
'amount': '10.0',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'red wine',
'payer': 2,
'payed_for': [1],
'amount': '20',
})
self.client.post("/raclette/add", data={
'date': '2011-08-10',
'what': 'delicatessen',
'payer': 1,
'payed_for': [1, 2],
'amount': '10',
})
project = models.Project.query.get('raclette')
transactions = project.get_transactions_to_settle_bill()
members = defaultdict(int)
# We should have the same values between transactions and project balances
for t in transactions:
members[t['ower']] -= t['amount']
members[t['receiver']] += t['amount']
balance = models.Project.query.get("raclette").balance
for m, a in members.items():
assert abs(a - balance[m.id]) < 0.01
return
def test_settle_zero(self):
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={'name': 'alexis'})
self.client.post("/raclette/members/add", data={'name': 'fred'})
self.client.post("/raclette/members/add", data={'name': 'tata'})
# create bills
self.client.post("/raclette/add", data={
'date': '2016-12-31',
'what': 'fromage à raclette',
'payer': 1,
'payed_for': [1, 2, 3],
'amount': '10.0',
})
self.client.post("/raclette/add", data={
'date': '2016-12-31',
'what': 'red wine',
'payer': 2,
'payed_for': [1, 3],
'amount': '20',
})
self.client.post("/raclette/add", data={
'date': '2017-01-01',
'what': 'refund',
'payer': 3,
'payed_for': [2],
'amount': '13.33',
})
project = models.Project.query.get('raclette')
transactions = project.get_transactions_to_settle_bill()
# There should not be any zero-amount transfer after rounding
for t in transactions:
rounded_amount = round(t['amount'], 2)
self.assertNotEqual(0.0, rounded_amount,
msg='%f is equal to zero after rounding' % t['amount'])
def test_export(self):
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={'name': 'alexis', 'weight': 2})
self.client.post("/raclette/members/add", data={'name': 'fred'})
self.client.post("/raclette/members/add", data={'name': 'tata'})
self.client.post("/raclette/members/add", data={'name': 'pépé'})
# create bills
self.client.post("/raclette/add", data={
'date': '2016-12-31',
'what': 'fromage à raclette',
'payer': 1,
'payed_for': [1, 2, 3, 4],
'amount': '10.0',
})
self.client.post("/raclette/add", data={
'date': '2016-12-31',
'what': 'red wine',
'payer': 2,
'payed_for': [1, 3],
'amount': '200',
})
self.client.post("/raclette/add", data={
'date': '2017-01-01',
'what': 'refund',
'payer': 3,
'payed_for': [2],
'amount': '13.33',
})
# generate json export of bills
resp = self.client.get("/raclette/export/bills.json")
expected = [{
'date': '2017-01-01',
'what': 'refund',
'amount': 13.33,
'payer_name': 'tata',
'payer_weight': 1.0,
'owers': ['fred']
}, {
'date': '2016-12-31',
'what': 'red wine',
'amount': 200.0,
'payer_name': 'fred',
'payer_weight': 1.0,
'owers': ['alexis', 'tata']
}, {
'date': '2016-12-31',
'what': 'fromage \xe0 raclette',
'amount': 10.0,
'payer_name': 'alexis',
'payer_weight': 2.0,
'owers': ['alexis', 'fred', 'tata', 'p\xe9p\xe9']
}]
self.assertEqual(json.loads(resp.data.decode('utf-8')), expected)
# generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv")
expected = [
"date,what,amount,payer_name,payer_weight,owers",
"2017-01-01,refund,13.33,tata,1.0,fred",
"2016-12-31,red wine,200.0,fred,1.0,\"alexis, tata\"",
"2016-12-31,fromage à raclette,10.0,alexis,2.0,\"alexis, fred, tata, pépé\""]
received_lines = resp.data.decode('utf-8').split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")),
set(received_lines[i].strip("\r").split(","))
)
# generate json export of transactions
resp = self.client.get("/raclette/export/transactions.json")
expected = [
{"amount": 2.00, "receiver": "fred", "ower": "p\xe9p\xe9"},
{"amount": 55.34, "receiver": "fred", "ower": "tata"},
{"amount": 127.33, "receiver": "fred", "ower": "alexis"},
]
self.assertEqual(json.loads(resp.data.decode('utf-8')), expected)
# generate csv export of transactions
resp = self.client.get("/raclette/export/transactions.csv")
expected = [
"amount,receiver,ower",
"2.0,fred,pépé",
"55.34,fred,tata",
"127.33,fred,alexis",
]
received_lines = resp.data.decode('utf-8').split("\n")
for i, line in enumerate(expected):
self.assertEqual(
set(line.split(",")),
set(received_lines[i].strip("\r").split(","))
)
# wrong export_format should return a 404
resp = self.client.get("/raclette/export/transactions.wrong")
self.assertEqual(resp.status_code, 404)
class APITestCase(IhatemoneyTestCase):
"""Tests the API"""
def api_create(self, name, id=None, password=None, contact=None):
id = id or name
password = password or name
contact = contact or "%s@notmyidea.org" % name
return self.client.post("/api/projects", data={
'name': name,
'id': id,
'password': password,
'contact_email': contact
})
def api_add_member(self, project, name, weight=1):
self.client.post("/api/projects/%s/members" % project,
data={"name": name, "weight": weight},
headers=self.get_auth(project))
def get_auth(self, username, password=None):
password = password or username
base64string = utils.base64_encode(
('%s:%s' % (username, password)).encode('utf-8')).decode('utf-8').replace('\n', '')
return {"Authorization": "Basic %s" % base64string}
def test_cors_requests(self):
# Create a project and test that CORS headers are present if requested.
resp = self.api_create("raclette")
self.assertStatus(201, resp)
# Try to do an OPTIONS requests and see if the headers are correct.
resp = self.client.options("/api/projects/raclette",
headers=self.get_auth("raclette"))
self.assertEqual(resp.headers['Access-Control-Allow-Origin'], '*')
def test_basic_auth(self):
# create a project
resp = self.api_create("raclette")
self.assertStatus(201, resp)
# try to do something on it being unauth should return a 401
resp = self.client.get("/api/projects/raclette")
self.assertStatus(401, resp)
# PUT / POST / DELETE / GET on the different resources
# should also return a 401
for verb in ('post',):
for resource in ("/raclette/members", "/raclette/bills"):
url = "/api/projects" + resource
self.assertStatus(401, getattr(self.client, verb)(url),
verb + resource)
for verb in ('get', 'delete', 'put'):
for resource in ("/raclette", "/raclette/members/1",
"/raclette/bills/1"):
url = "/api/projects" + resource
self.assertStatus(401, getattr(self.client, verb)(url),
verb + resource)
def test_project(self):
# wrong email should return an error
resp = self.client.post("/api/projects", data={
'name': "raclette",
'id': "raclette",
'password': "raclette",
'contact_email': "not-an-email"
})
self.assertTrue(400, resp.status_code)
self.assertEqual('{"contact_email": ["Invalid email address."]}\n',
resp.data.decode('utf-8'))
# create it
resp = self.api_create("raclette")
self.assertTrue(201, resp.status_code)
# create it twice should return a 400
resp = self.api_create("raclette")
self.assertTrue(400, resp.status_code)
self.assertIn('id', json.loads(resp.data.decode('utf-8')))
# get information about it
resp = self.client.get("/api/projects/raclette",
headers=self.get_auth("raclette"))
self.assertTrue(200, resp.status_code)
expected = {
"members": [],
"name": "raclette",
"contact_email": "raclette@notmyidea.org",
"id": "raclette",
}
decoded_resp = json.loads(resp.data.decode('utf-8'))
self.assertDictEqual(decoded_resp, expected)
# edit should work
resp = self.client.put("/api/projects/raclette", data={
"contact_email": "yeah@notmyidea.org",
"password": "raclette",
"name": "The raclette party",
}, headers=self.get_auth("raclette"))
self.assertEqual(200, resp.status_code)
resp = self.client.get("/api/projects/raclette",
headers=self.get_auth("raclette"))
self.assertEqual(200, resp.status_code)
expected = {
"name": "The raclette party",
"contact_email": "yeah@notmyidea.org",
"members": [],
"id": "raclette",
}
decoded_resp = json.loads(resp.data.decode('utf-8'))
self.assertDictEqual(decoded_resp, expected)
# password change is possible via API
resp = self.client.put("/api/projects/raclette", data={
"contact_email": "yeah@notmyidea.org",
"password": "tartiflette",
"name": "The raclette party",
}, headers=self.get_auth("raclette"))
self.assertEqual(200, resp.status_code)
resp = self.client.get("/api/projects/raclette",
headers=self.get_auth(
"raclette", "tartiflette"))
self.assertEqual(200, resp.status_code)
# delete should work
resp = self.client.delete("/api/projects/raclette",
headers=self.get_auth(
"raclette", "tartiflette"))
# get should return a 401 on an unknown resource
resp = self.client.get("/api/projects/raclette",
headers=self.get_auth("raclette"))
self.assertEqual(401, resp.status_code)
def test_member(self):
# create a project
self.api_create("raclette")
# get the list of members (should be empty)
req = self.client.get("/api/projects/raclette/members",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual('[]\n', req.data.decode('utf-8'))
# add a member
req = self.client.post("/api/projects/raclette/members", data={
"name": "Alexis"
}, headers=self.get_auth("raclette"))
# the id of the new member should be returned
self.assertStatus(201, req)
self.assertEqual("1\n", req.data.decode('utf-8'))
# the list of members should contain one member
req = self.client.get("/api/projects/raclette/members",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual(len(json.loads(req.data.decode('utf-8'))), 1)
# Try to add another member with the same name.
req = self.client.post("/api/projects/raclette/members", data={
"name": "Alexis"
}, headers=self.get_auth("raclette"))
self.assertStatus(400, req)
# edit the member
req = self.client.put("/api/projects/raclette/members/1", data={
"name": "Fred",
"weight": 2,
}, headers=self.get_auth("raclette"))
self.assertStatus(200, req)
# get should return the new name
req = self.client.get("/api/projects/raclette/members/1",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual("Fred", json.loads(req.data.decode('utf-8'))["name"])
self.assertEqual(2, json.loads(req.data.decode('utf-8'))["weight"])
# edit this member with same information
# (test PUT idemopotence)
req = self.client.put("/api/projects/raclette/members/1", data={
"name": "Fred"
}, headers=self.get_auth("raclette"))
self.assertStatus(200, req)
# de-activate the user
req = self.client.put("/api/projects/raclette/members/1", data={
"name": "Fred",
"activated": False,
}, headers=self.get_auth("raclette"))
self.assertStatus(200, req)
req = self.client.get("/api/projects/raclette/members/1",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual(False, json.loads(req.data.decode('utf-8'))["activated"])
# re-activate the user
req = self.client.put("/api/projects/raclette/members/1", data={
"name": "Fred",
"activated": True,
}, headers=self.get_auth("raclette"))
req = self.client.get("/api/projects/raclette/members/1",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual(True, json.loads(req.data.decode('utf-8'))["activated"])
# delete a member
req = self.client.delete("/api/projects/raclette/members/1",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
# the list of members should be empty
req = self.client.get("/api/projects/raclette/members",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual('[]\n', req.data.decode('utf-8'))
def test_bills(self):
# create a project
self.api_create("raclette")
# add members
self.api_add_member("raclette", "alexis")
self.api_add_member("raclette", "fred")
self.api_add_member("raclette", "arnaud")
# get the list of bills (should be empty)
req = self.client.get("/api/projects/raclette/bills",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual("[]\n", req.data.decode('utf-8'))
# add a bill
req = self.client.post("/api/projects/raclette/bills", data={
'date': '2011-08-10',
'what': 'fromage',
'payer': "1",
'payed_for': ["1", "2"],
'amount': '25',
}, headers=self.get_auth("raclette"))
# should return the id
self.assertStatus(201, req)
self.assertEqual(req.data.decode('utf-8'), "1\n")
# get this bill details
req = self.client.get("/api/projects/raclette/bills/1",
headers=self.get_auth("raclette"))
# compare with the added info
self.assertStatus(200, req)
expected = {
"what": "fromage",
"payer_id": 1,
"owers": [
{"activated": True, "id": 1, "name": "alexis", "weight": 1},
{"activated": True, "id": 2, "name": "fred", "weight": 1}],
"amount": 25.0,
"date": "2011-08-10",
"id": 1}
got = json.loads(req.data.decode('utf-8'))
self.assertEqual(
datetime.date.today(),
datetime.datetime.strptime(got["creation_date"], '%Y-%m-%d').date()
)
del got["creation_date"]
self.assertDictEqual(expected, got)
# the list of bills should length 1
req = self.client.get("/api/projects/raclette/bills",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual(1, len(json.loads(req.data.decode('utf-8'))))
# edit with errors should return an error
req = self.client.put("/api/projects/raclette/bills/1", data={
'date': '201111111-08-10', # not a date
'what': 'fromage',
'payer': "1",
'payed_for': ["1", "2"],
'amount': '25',
}, headers=self.get_auth("raclette"))
self.assertStatus(400, req)
self.assertEqual('{"date": ["This field is required."]}\n', req.data.decode('utf-8'))
# edit a bill
req = self.client.put("/api/projects/raclette/bills/1", data={
'date': '2011-09-10',
'what': 'beer',
'payer': "2",
'payed_for': ["1", "2"],
'amount': '25',
}, headers=self.get_auth("raclette"))
# check its fields
req = self.client.get("/api/projects/raclette/bills/1",
headers=self.get_auth("raclette"))
creation_date = datetime.datetime.strptime(
json.loads(req.data.decode('utf-8'))["creation_date"],
'%Y-%m-%d'
).date()
expected = {
"what": "beer",
"payer_id": 2,
"owers": [
{"activated": True, "id": 1, "name": "alexis", "weight": 1},
{"activated": True, "id": 2, "name": "fred", "weight": 1}],
"amount": 25.0,
"date": "2011-09-10",
"id": 1}
got = json.loads(req.data.decode('utf-8'))
self.assertEqual(
creation_date,
datetime.datetime.strptime(got["creation_date"], '%Y-%m-%d').date()
)
del got["creation_date"]
self.assertDictEqual(expected, got)
# delete a bill
req = self.client.delete("/api/projects/raclette/bills/1",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
# getting it should return a 404
req = self.client.get("/api/projects/raclette/bills/1",
headers=self.get_auth("raclette"))
self.assertStatus(404, req)
def test_bills_with_calculation(self):
# create a project
self.api_create("raclette")
# add members
self.api_add_member("raclette", "alexis")
self.api_add_member("raclette", "fred")
# valid amounts
input_expected = [
("((100 + 200.25) * 2 - 100) / 2", 250.25),
("3/2", 1.5),
("2 + 1 * 5 - 2 / 1", 5),
]
for i, pair in enumerate(input_expected):
input_amount, expected_amount = pair
id = i + 1
req = self.client.post(
"/api/projects/raclette/bills",
data={
'date': '2011-08-10',
'what': 'fromage',
'payer': "1",
'payed_for': ["1", "2"],
'amount': input_amount,
},
headers=self.get_auth("raclette")
)
# should return the id
self.assertStatus(201, req)
self.assertEqual(req.data.decode('utf-8'), "{}\n".format(id))
# get this bill's details
req = self.client.get(
"/api/projects/raclette/bills/{}".format(id),
headers=self.get_auth("raclette")
)
# compare with the added info
self.assertStatus(200, req)
expected = {
"what": "fromage",
"payer_id": 1,
"owers": [
{"activated": True, "id": 1, "name": "alexis", "weight": 1},
{"activated": True, "id": 2, "name": "fred", "weight": 1}],
"amount": expected_amount,
"date": "2011-08-10",
"id": id,
}
got = json.loads(req.data.decode('utf-8'))
self.assertEqual(
datetime.date.today(),
datetime.datetime.strptime(got["creation_date"], '%Y-%m-%d').date()
)
del got["creation_date"]
self.assertDictEqual(expected, got)
# should raise errors
erroneous_amounts = [
"lambda ", # letters
"(20 + 2", # invalid expression
"20/0", # invalid calc
"9999**99999999999999999", # exponents
"2" * 201, # greater than 200 chars,
]
for amount in erroneous_amounts:
req = self.client.post("/api/projects/raclette/bills", data={
'date': '2011-08-10',
'what': 'fromage',
'payer': "1",
'payed_for': ["1", "2"],
'amount': amount,
}, headers=self.get_auth("raclette"))
self.assertStatus(400, req)
def test_statistics(self):
# create a project
self.api_create("raclette")
# add members
self.api_add_member("raclette", "alexis")
self.api_add_member("raclette", "fred")
# add a bill
req = self.client.post("/api/projects/raclette/bills", data={
'date': '2011-08-10',
'what': 'fromage',
'payer': "1",
'payed_for': ["1", "2"],
'amount': '25',
}, headers=self.get_auth("raclette"))
# get the list of bills (should be empty)
req = self.client.get("/api/projects/raclette/statistics",
headers=self.get_auth("raclette"))
self.assertStatus(200, req)
self.assertEqual([
{'balance': 12.5,
'member': {'activated': True, 'id': 1,
'name': 'alexis', 'weight': 1.0},
'paid': 25.0,
'spent': 12.5},
{'balance': -12.5,
'member': {'activated': True, 'id': 2,
'name': 'fred', 'weight': 1.0},
'paid': 0,
'spent': 12.5}],
json.loads(req.data.decode('utf-8')))
def test_username_xss(self):
# create a project
# self.api_create("raclette")
self.post_project("raclette")
self.login("raclette")
# add members
self.api_add_member("raclette", "
|---|