from collections import defaultdict
import io
import json
import re
from time import sleep
import unittest
from unittest.mock import MagicMock
from flask import session
import pytest
from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.versioning import LoggingMode
class BudgetTestCase(IhatemoneyTestCase):
def test_notifications(self):
"""Test that the notifications are sent, and that email addresses
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")
resp = self.client.post(
"/raclette/invite",
data={"emails": "zorglub@notmyidea.org"},
follow_redirects=True,
)
# success notification
self.assertIn("Your invitations have been sent", resp.data.decode("utf-8"))
self.assertEqual(len(outbox), 2)
self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"])
self.assertEqual(outbox[1].recipients, ["zorglub@notmyidea.org"])
# sending a message to multiple persons
with self.app.mail.record_messages() as outbox:
self.client.post(
"/raclette/invite",
data={"emails": "zorglub@notmyidea.org, toto@notmyidea.org"},
)
# only one message is sent to multiple persons
self.assertEqual(len(outbox), 1)
self.assertEqual(
outbox[0].recipients, ["zorglub@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 addresses shouldn't send any messages
with self.app.mail.record_messages() as outbox:
self.client.post(
"/raclette/invite", data={"emails": "zorglub@notmyidea.org, zorglub"}
) # 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:
with self.app.mail.record_messages() as outbox:
# add a valid project
resp = c.post(
"/create",
data={
"name": "The fabulous raclette party",
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
follow_redirects=True,
)
# an email is sent to the owner with a reminder of the password
self.assertEqual(len(outbox), 1)
self.assertEqual(outbox[0].recipients, ["raclette@notmyidea.org"])
self.assertIn(
"A reminder email has just been sent to you",
resp.data.decode("utf-8"),
)
# 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",
"default_currency": "USD",
},
)
# no new project added
self.assertEqual(len(models.Project.query.all()), 1)
def test_project_creation_without_public_permissions(self):
self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"] = False
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",
"default_currency": "USD",
},
)
# session is not updated
self.assertNotIn("raclette", session)
# project is not created
self.assertEqual(len(models.Project.query.all()), 0)
def test_project_creation_with_public_permissions(self):
self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"] = True
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",
"default_currency": "USD",
},
)
# session is updated
self.assertTrue(session["raclette"])
# project is created
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",
"default_currency": "USD",
},
)
# project added
self.assertEqual(len(models.Project.query.all()), 1)
c.post(
"/raclette/delete",
data={"password": "party"},
)
# 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
from ihatemoney.web import login_throttler
login_throttler._delay = 0.005
# 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": "zorglub"})
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(
f"/raclette/edit/{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.post(f"/raclette/delete/{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": "zorglub"})
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_trimmed_members(self):
self.post_project("raclette")
# Add two times the same person (with a space at the end).
self.client.post("/raclette/members/add", data={"name": "zorglub"})
self.client.post("/raclette/members/add", data={"name": "zorglub "})
members = models.Project.query.get("raclette").members
self.assertEqual(len(members), 1)
def test_weighted_members_list(self):
self.post_project("raclette")
# add two persons
self.client.post("/raclette/members/add", data={"name": "zorglub"})
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": "zorglub"})
resp = self.client.post(
"/raclette/members/1/edit", data={"name": "zorglub", "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": "zorglub"})
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 theoretical ones if we
# round to 2 decimals, like in the UI.
for key, value in balance.items():
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": "zorglub@notmyidea.org",
"password": "didoudida",
"logging_preference": LoggingMode.ENABLED.value,
"default_currency": "USD",
}
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.assertEqual(project.default_currency, new_data["default_currency"])
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):
# Output is checked with the USD sign
self.post_project("raclette", default_currency="USD")
# add members
self.client.post("/raclette/members/add", data={"name": "zorglub", "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": "pépé"})
# 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")
regex = r" | {} | \s+{} | \s+{} | "
self.assertRegex(
response.data.decode("utf-8"),
regex.format("zorglub", r"\$20\.00", r"\$31\.67"),
)
self.assertRegex(
response.data.decode("utf-8"),
regex.format("fred", r"\$20\.00", r"\$5\.83"),
)
self.assertRegex(
response.data.decode("utf-8"), regex.format("tata", r"\$0\.00", r"\$2\.50")
)
self.assertRegex(
response.data.decode("utf-8"), regex.format("pépé", r"\$0\.00", r"\$0\.00")
)
# Check that the order of participants in the sidebar table is the
# same as in the main table.
order = ["fred", "pépé", "tata", "zorglub"]
regex1 = r".*".join(
r"{} | ".format(name) for name in order
)
regex2 = r".*".join(
r"{} | ".format(name) for name in order
)
# Build the regexp ourselves to be able to pass the DOTALL flag
# (so that ".*" matches newlines)
self.assertRegex(response.data.decode("utf-8"), re.compile(regex1, re.DOTALL))
self.assertRegex(response.data.decode("utf-8"), re.compile(regex2, re.DOTALL))
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": "zorglub"})
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": "pépé"})
# 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": "zorglub"})
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"{t['amount']} is equal to zero after rounding",
)
def test_export(self):
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={"name": "zorglub", "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",
"original_currency": "USD",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "red wine",
"payer": 2,
"payed_for": [1, 3],
"amount": "200",
"original_currency": "USD",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "refund",
"payer": 3,
"payed_for": [2],
"amount": "13.33",
"original_currency": "USD",
},
)
# 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": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage \xe0 raclette",
"amount": 10.0,
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "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,"zorglub, tata"',
'2016-12-31,fromage à raclette,10.0,zorglub,2.0,"zorglub, 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": "zorglub"},
]
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,zorglub",
]
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)
def test_import_new_project(self):
# Import JSON in an empty project
self.post_project("raclette")
self.login("raclette")
project = models.Project.query.get("raclette")
json_to_import = [
{
"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": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage a raclette",
"amount": 10.0,
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "pepe"],
},
]
from ihatemoney.web import import_project
file = io.StringIO()
json.dump(json_to_import, file)
file.seek(0)
import_project(file, project)
bills = project.get_pretty_bills()
# Check if all bills has been add
self.assertEqual(len(bills), len(json_to_import))
# Check if name of bills are ok
b = [e["what"] for e in bills]
b.sort()
ref = [e["what"] for e in json_to_import]
ref.sort()
self.assertEqual(b, ref)
# Check if other informations in bill are ok
for i in json_to_import:
for j in bills:
if j["what"] == i["what"]:
self.assertEqual(j["payer_name"], i["payer_name"])
self.assertEqual(j["amount"], i["amount"])
self.assertEqual(j["payer_weight"], i["payer_weight"])
self.assertEqual(j["date"], i["date"])
list_project = [ower for ower in j["owers"]]
list_project.sort()
list_json = [ower for ower in i["owers"]]
list_json.sort()
self.assertEqual(list_project, list_json)
def test_import_partial_project(self):
# Import a JSON in a project with already existing data
self.post_project("raclette")
self.login("raclette")
project = models.Project.query.get("raclette")
self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2})
self.client.post("/raclette/members/add", data={"name": "fred"})
self.client.post("/raclette/members/add", data={"name": "tata"})
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "red wine",
"payer": 2,
"payed_for": [1, 3],
"amount": "200",
},
)
json_to_import = [
{
"date": "2017-01-01",
"what": "refund",
"amount": 13.33,
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["fred"],
},
{ # This expense does not have to be present twice.
"date": "2016-12-31",
"what": "red wine",
"amount": 200.0,
"payer_name": "fred",
"payer_weight": 1.0,
"owers": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage a raclette",
"amount": 10.0,
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "fred", "tata", "pepe"],
},
]
from ihatemoney.web import import_project
file = io.StringIO()
json.dump(json_to_import, file)
file.seek(0)
import_project(file, project)
bills = project.get_pretty_bills()
# Check if all bills has been add
self.assertEqual(len(bills), len(json_to_import))
# Check if name of bills are ok
b = [e["what"] for e in bills]
b.sort()
ref = [e["what"] for e in json_to_import]
ref.sort()
self.assertEqual(b, ref)
# Check if other informations in bill are ok
for i in json_to_import:
for j in bills:
if j["what"] == i["what"]:
self.assertEqual(j["payer_name"], i["payer_name"])
self.assertEqual(j["amount"], i["amount"])
self.assertEqual(j["payer_weight"], i["payer_weight"])
self.assertEqual(j["date"], i["date"])
list_project = [ower for ower in j["owers"]]
list_project.sort()
list_json = [ower for ower in i["owers"]]
list_json.sort()
self.assertEqual(list_project, list_json)
def test_import_wrong_json(self):
self.post_project("raclette")
self.login("raclette")
project = models.Project.query.get("raclette")
json_1 = [
{ # wrong keys
"checked": False,
"dimensions": {"width": 5, "height": 10},
"id": 1,
"name": "A green door",
"price": 12.5,
"tags": ["home", "green"],
}
]
json_2 = [
{ # amount missing
"date": "2017-01-01",
"what": "refund",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["fred"],
}
]
from ihatemoney.web import import_project
try:
file = io.StringIO()
json.dump(json_1, file)
file.seek(0)
import_project(file, project)
except ValueError:
self.assertTrue(True)
except Exception:
self.fail("unexpected exception raised")
else:
self.fail("ExpectedException not raised")
try:
file = io.StringIO()
json.dump(json_2, file)
file.seek(0)
import_project(file, project)
except ValueError:
self.assertTrue(True)
except Exception:
self.fail("unexpected exception raised")
else:
self.fail("ExpectedException not raised")
def test_access_other_projects(self):
"""Test that accessing or editing bills and members from another project fails"""
# Create project
self.post_project("raclette")
# Add members
self.client.post("/raclette/members/add", data={"name": "zorglub", "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 bill
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2, 3, 4],
"amount": "10.0",
},
)
# Ensure it has been created
raclette = models.Project.query.get("raclette")
self.assertEqual(raclette.get_bills().count(), 1)
# Log out
self.client.get("/exit")
# Create and log in as another project
self.post_project("tartiflette")
modified_bill = {
"date": "2018-12-31",
"what": "roblochon",
"payer": 2,
"payed_for": [1, 3, 4],
"amount": "100.0",
}
# Try to access bill of another project
resp = self.client.get("/raclette/edit/1")
self.assertStatus(303, resp)
# Try to access bill of another project by ID
resp = self.client.get("/tartiflette/edit/1")
self.assertStatus(404, resp)
# Try to edit bill
resp = self.client.post("/raclette/edit/1", data=modified_bill)
self.assertStatus(303, resp)
# Try to edit bill by ID
resp = self.client.post("/tartiflette/edit/1", data=modified_bill)
self.assertStatus(404, resp)
# Try to delete bill
resp = self.client.post("/raclette/delete/1")
self.assertStatus(303, resp)
# Try to delete bill by ID
resp = self.client.post("/tartiflette/delete/1")
self.assertStatus(302, resp)
# Additional check that the bill was indeed not modified or deleted
bill = models.Bill.query.filter(models.Bill.id == 1).one()
self.assertEqual(bill.what, "fromage à raclette")
# Use the correct credentials to modify and delete the bill.
# This ensures that modifying and deleting the bill can actually work
self.client.get("/exit")
self.client.post(
"/authenticate", data={"id": "raclette", "password": "raclette"}
)
self.client.post("/raclette/edit/1", data=modified_bill)
bill = models.Bill.query.filter(models.Bill.id == 1).one_or_none()
self.assertNotEqual(bill, None, "bill not found")
self.assertEqual(bill.what, "roblochon")
self.client.post("/raclette/delete/1")
bill = models.Bill.query.filter(models.Bill.id == 1).one_or_none()
self.assertEqual(bill, None)
# Switch back to the second project
self.client.get("/exit")
self.client.post(
"/authenticate", data={"id": "tartiflette", "password": "tartiflette"}
)
modified_member = {
"name": "bulgroz",
"weight": 42,
}
# Try to access member from another project
resp = self.client.get("/raclette/members/1/edit")
self.assertStatus(303, resp)
# Try to access member by ID
resp = self.client.get("/tartiflette/members/1/edit")
self.assertStatus(404, resp)
# Try to edit member
resp = self.client.post("/raclette/members/1/edit", data=modified_member)
self.assertStatus(303, resp)
# Try to edit member by ID
resp = self.client.post("/tartiflette/members/1/edit", data=modified_member)
self.assertStatus(404, resp)
# Try to delete member
resp = self.client.post("/raclette/members/1/delete")
self.assertStatus(303, resp)
# Try to delete member by ID
resp = self.client.post("/tartiflette/members/1/delete")
self.assertStatus(302, resp)
# Additional check that the member was indeed not modified or deleted
member = models.Person.query.filter(models.Person.id == 1).one_or_none()
self.assertNotEqual(member, None, "member not found")
self.assertEqual(member.name, "zorglub")
self.assertTrue(member.activated)
# Use the correct credentials to modify and delete the member.
# This ensures that modifying and deleting the member can actually work
self.client.get("/exit")
self.client.post(
"/authenticate", data={"id": "raclette", "password": "raclette"}
)
self.client.post("/raclette/members/1/edit", data=modified_member)
member = models.Person.query.filter(models.Person.id == 1).one()
self.assertEqual(member.name, "bulgroz")
self.client.post("/raclette/members/1/delete")
member = models.Person.query.filter(models.Person.id == 1).one_or_none()
self.assertEqual(member, None)
def test_currency_switch(self):
mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# A project should be editable
self.post_project("raclette")
# add members
self.client.post("/raclette/members/add", data={"name": "zorglub"})
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")
# First all converted_amount should be the same as amount, with no currency
for bill in project.get_bills():
assert bill.original_currency == CurrencyConverter.no_currency
assert bill.amount == bill.converted_amount
# Then, switch to EUR, all bills must have been changed to this currency
project.switch_currency("EUR")
for bill in project.get_bills():
assert bill.original_currency == "EUR"
assert bill.amount == bill.converted_amount
# Add a bill in EUR, the current default currency
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "refund from EUR",
"payer": 3,
"payed_for": [2],
"amount": "20",
"original_currency": "EUR",
},
)
last_bill = project.get_bills().first()
assert last_bill.converted_amount == last_bill.amount
# Erase all currencies
project.switch_currency(CurrencyConverter.no_currency)
for bill in project.get_bills():
assert bill.original_currency == CurrencyConverter.no_currency
assert bill.amount == bill.converted_amount
# Let's go back to EUR to test conversion
project.switch_currency("EUR")
# This is a bill in CAD
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "Poutine",
"payer": 3,
"payed_for": [2],
"amount": "18",
"original_currency": "CAD",
},
)
last_bill = project.get_bills().first()
expected_amount = converter.exchange_currency(last_bill.amount, "CAD", "EUR")
assert last_bill.converted_amount == expected_amount
# Switch to USD. Now, NO bill should be in USD, since they already had a currency
project.switch_currency("USD")
for bill in project.get_bills():
assert bill.original_currency != "USD"
expected_amount = converter.exchange_currency(
bill.amount, bill.original_currency, "USD"
)
assert bill.converted_amount == expected_amount
# Switching back to no currency must fail
with pytest.raises(ValueError):
project.switch_currency(CurrencyConverter.no_currency)
# It also must fails with a nice error using the form
resp = self.client.post(
"/raclette/edit",
data={
"name": "demonstration",
"password": "demo",
"contact_email": "demo@notmyidea.org",
"project_history": "y",
"default_currency": converter.no_currency,
},
)
# A user displayed error should be generated, and its currency should be the same.
self.assertStatus(200, resp)
self.assertIn('', resp.data.decode("utf-8"))
self.assertEqual(models.Project.query.get("raclette").default_currency, "USD")
def test_currency_switch_to_bill_currency(self):
mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# Default currency is 'XXX', but we should start from a project with a currency
self.post_project("raclette", default_currency="USD")
# add members
self.client.post("/raclette/members/add", data={"name": "zorglub"})
self.client.post("/raclette/members/add", data={"name": "fred"})
# Bill with a different currency than project's default
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2],
"amount": "10.0",
"original_currency": "EUR",
},
)
project = models.Project.query.get("raclette")
bill = project.get_bills().first()
assert bill.converted_amount == converter.exchange_currency(
bill.amount, "EUR", "USD"
)
# And switch project to the currency from the bill we created
project.switch_currency("EUR")
bill = project.get_bills().first()
assert bill.converted_amount == bill.amount
def test_currency_switch_to_no_currency(self):
mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# Default currency is 'XXX', but we should start from a project with a currency
self.post_project("raclette", default_currency="USD")
# add members
self.client.post("/raclette/members/add", data={"name": "zorglub"})
self.client.post("/raclette/members/add", data={"name": "fred"})
# Bills with a different currency than project's default
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2],
"amount": "10.0",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "aspirine",
"payer": 2,
"payed_for": [1, 2],
"amount": "5.0",
"original_currency": "EUR",
},
)
project = models.Project.query.get("raclette")
for bill in project.get_bills_unordered():
assert bill.converted_amount == converter.exchange_currency(
bill.amount, "EUR", "USD"
)
# And switch project to no currency: amount should be equal to what was submitted
project.switch_currency(converter.no_currency)
no_currency_bills = [
(bill.amount, bill.converted_amount) for bill in project.get_bills()
]
assert no_currency_bills == [(5.0, 5.0), (10.0, 10.0)]
if __name__ == "__main__":
unittest.main()
---|