mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-30 10:12:37 +02:00

Currently the string representation of a Bill is: "<amount> for <description>" It is used in the History Page to describe changes that were applied to Bills, for instance: Bill "42.0 for Test" renamed to "Another Test" This is inconsistent, not easy to read, and the "for" in the middle is not translatable. To solve this issue, simply switch the string representation of a bill to its description. Co-authored-by: Baptiste Jonglez <git@bitsofnetworks.org>
2854 lines
96 KiB
Python
2854 lines
96 KiB
Python
import base64
|
|
from collections import defaultdict
|
|
import datetime
|
|
import io
|
|
import json
|
|
import os
|
|
from time import sleep
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from flask import session
|
|
from flask_testing import TestCase
|
|
from sqlalchemy import orm
|
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
|
|
from ihatemoney import history, models, utils
|
|
from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash
|
|
from ihatemoney.run import create_app, db, load_configuration
|
|
from ihatemoney.versioning import LoggingMode
|
|
|
|
# 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": f"{name}@notmyidea.org",
|
|
},
|
|
)
|
|
|
|
def create_project(self, name):
|
|
project = models.Project(
|
|
id=name,
|
|
name=str(name),
|
|
password=generate_password_hash(name),
|
|
contact_email=f"{name}@notmyidea.org",
|
|
)
|
|
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,
|
|
f"{url} expected {expected}, got {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 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")
|
|
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 addresses 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 <a href="/raclette/members/add"',
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
# Test empty and invalid tokens
|
|
self.client.get("/exit")
|
|
resp = self.client.get("/authenticate")
|
|
self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
|
|
resp = self.client.get("/authenticate?token=token")
|
|
self.assertIn("You either provided a bad token", resp.data.decode("utf-8"))
|
|
|
|
def test_password_reminder(self):
|
|
# test that it is possible to have an email containing the password of a
|
|
# project in case people forget it (and it happens!)
|
|
|
|
self.create_project("raclette")
|
|
|
|
with self.app.mail.record_messages() as outbox:
|
|
# a nonexisting project should not send an email
|
|
self.client.post("/password-reminder", data={"id": "unexisting"})
|
|
self.assertEqual(len(outbox), 0)
|
|
|
|
# a mail should be sent when a project exists
|
|
self.client.post("/password-reminder", data={"id": "raclette"})
|
|
self.assertEqual(len(outbox), 1)
|
|
self.assertIn("raclette", outbox[0].body)
|
|
self.assertIn("raclette@notmyidea.org", outbox[0].recipients)
|
|
|
|
def test_password_reset(self):
|
|
# test that a password can be changed using a link sent by mail
|
|
|
|
self.create_project("raclette")
|
|
# Get password resetting link from mail
|
|
with self.app.mail.record_messages() as outbox:
|
|
self.client.post("/password-reminder", data={"id": "raclette"})
|
|
self.assertEqual(len(outbox), 1)
|
|
url_start = outbox[0].body.find("You can reset it here: ") + 23
|
|
url_end = outbox[0].body.find(".\n", url_start)
|
|
url = outbox[0].body[url_start:url_end]
|
|
# Test that we got a valid token
|
|
resp = self.client.get(url)
|
|
self.assertIn("Password confirmation</label>", 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(
|
|
"<title>Account manager - raclette</title>", 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_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",
|
|
},
|
|
)
|
|
|
|
# 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",
|
|
},
|
|
)
|
|
|
|
# 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",
|
|
},
|
|
)
|
|
|
|
# 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 <a href="/raclette/members/add"',
|
|
result.data.decode("utf-8"),
|
|
)
|
|
|
|
result = self.client.post("/raclette/members/add", data={"name": "alexis"})
|
|
|
|
result = self.client.get("/raclette/")
|
|
|
|
# Empty bill with member, list should now propose to add bills
|
|
self.assertIn(
|
|
'You probably want to <a href="/raclette/add"', result.data.decode("utf-8")
|
|
)
|
|
|
|
def test_membership(self):
|
|
self.post_project("raclette")
|
|
self.login("raclette")
|
|
|
|
# adds a member to this project
|
|
self.client.post("/raclette/members/add", data={"name": "alexis"})
|
|
self.assertEqual(len(models.Project.query.get("raclette").members), 1)
|
|
|
|
# adds him twice
|
|
result = self.client.post("/raclette/members/add", data={"name": "alexis"})
|
|
|
|
# should not accept him
|
|
self.assertEqual(len(models.Project.query.get("raclette").members), 1)
|
|
|
|
# add fred
|
|
self.client.post("/raclette/members/add", data={"name": "fred"})
|
|
self.assertEqual(len(models.Project.query.get("raclette").members), 2)
|
|
|
|
# check fred is present in the bills page
|
|
result = self.client.get("/raclette/")
|
|
self.assertIn("fred", result.data.decode("utf-8"))
|
|
|
|
# remove fred
|
|
self.client.post(
|
|
"/raclette/members/%s/delete"
|
|
% models.Project.query.get("raclette").members[-1].id
|
|
)
|
|
|
|
# as fred is not bound to any bill, he is removed
|
|
self.assertEqual(len(models.Project.query.get("raclette").members), 1)
|
|
|
|
# add fred again
|
|
self.client.post("/raclette/members/add", data={"name": "fred"})
|
|
fred_id = models.Project.query.get("raclette").members[-1].id
|
|
|
|
# bound him to a bill
|
|
result = self.client.post(
|
|
"/raclette/add",
|
|
data={
|
|
"date": "2011-08-10",
|
|
"what": "fromage à raclette",
|
|
"payer": fred_id,
|
|
"payed_for": [fred_id],
|
|
"amount": "25",
|
|
},
|
|
)
|
|
|
|
# remove fred
|
|
self.client.post(f"/raclette/members/{fred_id}/delete")
|
|
|
|
# he is still in the database, but is deactivated
|
|
self.assertEqual(len(models.Project.query.get("raclette").members), 2)
|
|
self.assertEqual(len(models.Project.query.get("raclette").active_members), 1)
|
|
|
|
# as fred is now deactivated, check that he is not listed when adding
|
|
# a bill or displaying the balance
|
|
result = self.client.get("/raclette/")
|
|
self.assertNotIn(
|
|
(f"/raclette/members/{fred_id}/delete"), result.data.decode("utf-8")
|
|
)
|
|
|
|
result = self.client.get("/raclette/add")
|
|
self.assertNotIn("fred", result.data.decode("utf-8"))
|
|
|
|
# adding him again should reactivate him
|
|
self.client.post("/raclette/members/add", data={"name": "fred"})
|
|
self.assertEqual(len(models.Project.query.get("raclette").active_members), 2)
|
|
|
|
# adding an user with the same name as another user from a different
|
|
# project should not cause any troubles
|
|
self.post_project("randomid")
|
|
self.login("randomid")
|
|
self.client.post("/randomid/members/add", data={"name": "fred"})
|
|
self.assertEqual(len(models.Project.query.get("randomid").active_members), 1)
|
|
|
|
def test_person_model(self):
|
|
self.post_project("raclette")
|
|
self.login("raclette")
|
|
|
|
# adds a member to this project
|
|
self.client.post("/raclette/members/add", data={"name": "alexis"})
|
|
alexis = models.Project.query.get("raclette").members[-1]
|
|
|
|
# should not have any bills
|
|
self.assertFalse(alexis.has_bills())
|
|
|
|
# bound him to a bill
|
|
self.client.post(
|
|
"/raclette/add",
|
|
data={
|
|
"date": "2011-08-10",
|
|
"what": "fromage à raclette",
|
|
"payer": alexis.id,
|
|
"payed_for": [alexis.id],
|
|
"amount": "25",
|
|
},
|
|
)
|
|
|
|
# should have a bill now
|
|
alexis = models.Project.query.get("raclette").members[-1]
|
|
self.assertTrue(alexis.has_bills())
|
|
|
|
def test_member_delete_method(self):
|
|
self.post_project("raclette")
|
|
self.login("raclette")
|
|
|
|
# adds a member to this project
|
|
self.client.post("/raclette/members/add", data={"name": "alexis"})
|
|
|
|
# try to remove the member using GET method
|
|
response = self.client.get("/raclette/members/1/delete")
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
# delete user using POST method
|
|
self.client.post("/raclette/members/1/delete")
|
|
self.assertEqual(len(models.Project.query.get("raclette").active_members), 0)
|
|
# try to delete an user already deleted
|
|
self.client.post("/raclette/members/1/delete")
|
|
|
|
def test_demo(self):
|
|
# test that a demo project is created if none is defined
|
|
self.assertEqual([], models.Project.query.all())
|
|
self.client.get("/demo")
|
|
self.assertTrue(models.Project.query.get("demo") is not None)
|
|
|
|
def test_deactivated_demo(self):
|
|
self.app.config["ACTIVATE_DEMO_PROJECT"] = False
|
|
|
|
# test redirection to the create project form when demo is deactivated
|
|
resp = self.client.get("/demo")
|
|
self.assertIn('<a href="/create?project_id=demo">', 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('<a href="/admin?goto=%2Fcreate">', resp.data.decode("utf-8"))
|
|
|
|
# test right password
|
|
resp = self.client.post(
|
|
"/admin?goto=%2Fcreate", data={"admin_password": "pass"}
|
|
)
|
|
self.assertIn('<a href="/create">/create</a>', resp.data.decode("utf-8"))
|
|
|
|
# test wrong password
|
|
resp = self.client.post(
|
|
"/admin?goto=%2Fcreate", data={"admin_password": "wrong"}
|
|
)
|
|
self.assertNotIn('<a href="/create">/create</a>', resp.data.decode("utf-8"))
|
|
|
|
# test empty password
|
|
resp = self.client.post("/admin?goto=%2Fcreate", data={"admin_password": ""})
|
|
self.assertNotIn('<a href="/create">/create</a>', 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(
|
|
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.get(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": "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_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": "alexis"})
|
|
self.client.post("/raclette/members/add", data={"name": "alexis "})
|
|
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": "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('<p class="alert alert-danger">', 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 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": "alexis@notmyidea.org",
|
|
"password": "didoudida",
|
|
"logging_preference": LoggingMode.ENABLED.value,
|
|
}
|
|
|
|
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('<div class="alert alert-danger">', 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(
|
|
"<thead><tr><th>Project</th><th>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")
|
|
first_cell = '<td class="d-md-none">'
|
|
indent = "\n "
|
|
self.assertIn(
|
|
first_cell
|
|
+ "alexis</td>"
|
|
+ indent
|
|
+ "<td>20.00</td>"
|
|
+ indent
|
|
+ "<td>31.67</td>\n",
|
|
response.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
first_cell
|
|
+ "fred</td>"
|
|
+ indent
|
|
+ "<td>20.00</td>"
|
|
+ indent
|
|
+ "<td>5.83</td>\n",
|
|
response.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
first_cell
|
|
+ "tata</td>"
|
|
+ indent
|
|
+ "<td>0.00</td>"
|
|
+ indent
|
|
+ "<td>2.50</td>\n",
|
|
response.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
first_cell
|
|
+ "toto</td>"
|
|
+ indent
|
|
+ "<td>0.00</td>"
|
|
+ indent
|
|
+ "<td>0.00</td>\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"{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": "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)
|
|
|
|
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": ["alexis", "tata"],
|
|
},
|
|
{
|
|
"date": "2016-12-31",
|
|
"what": "fromage a raclette",
|
|
"amount": 10.0,
|
|
"payer_name": "alexis",
|
|
"payer_weight": 2.0,
|
|
"owers": ["alexis", "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": "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/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": ["alexis", "tata"],
|
|
},
|
|
{
|
|
"date": "2016-12-31",
|
|
"what": "fromage a raclette",
|
|
"amount": 10.0,
|
|
"payer_name": "alexis",
|
|
"payer_weight": 2.0,
|
|
"owers": ["alexis", "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")
|
|
|
|
|
|
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 f"{name}@notmyidea.org"
|
|
|
|
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(
|
|
f"/api/projects/{project}/members",
|
|
data={"name": name, "weight": weight},
|
|
headers=self.get_auth(project),
|
|
)
|
|
|
|
def get_auth(self, username, password=None):
|
|
password = password or username
|
|
base64string = (
|
|
base64.encodebytes(f"{username}:{password}".encode("utf-8"))
|
|
.decode("utf-8")
|
|
.replace("\n", "")
|
|
)
|
|
return {"Authorization": f"Basic {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",
|
|
"logging_preference": 1,
|
|
}
|
|
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",
|
|
"project_history": "y",
|
|
},
|
|
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",
|
|
"logging_preference": 1,
|
|
}
|
|
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_token_creation(self):
|
|
"""Test that token of project is generated
|
|
"""
|
|
|
|
# Create project
|
|
resp = self.api_create("raclette")
|
|
self.assertTrue(201, resp.status_code)
|
|
|
|
# Get token
|
|
resp = self.client.get(
|
|
"/api/projects/raclette/token", headers=self.get_auth("raclette")
|
|
)
|
|
|
|
self.assertEqual(200, resp.status_code)
|
|
|
|
decoded_resp = json.loads(resp.data.decode("utf-8"))
|
|
|
|
# Access with token
|
|
resp = self.client.get(
|
|
"/api/projects/raclette/token",
|
|
headers={"Authorization": f"Basic {decoded_resp['token']}"},
|
|
)
|
|
|
|
self.assertEqual(200, resp.status_code)
|
|
|
|
def test_token_login(self):
|
|
resp = self.api_create("raclette")
|
|
# Get token
|
|
resp = self.client.get(
|
|
"/api/projects/raclette/token", headers=self.get_auth("raclette")
|
|
)
|
|
decoded_resp = json.loads(resp.data.decode("utf-8"))
|
|
resp = self.client.get("/authenticate?token={}".format(decoded_resp["token"]))
|
|
# Test that we are redirected.
|
|
self.assertEqual(302, 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",
|
|
"external_link": "https://raclette.fr",
|
|
},
|
|
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,
|
|
"external_link": "https://raclette.fr",
|
|
}
|
|
|
|
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",
|
|
"external_link": "https://raclette.fr",
|
|
},
|
|
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",
|
|
"external_link": "https://raclette.fr",
|
|
},
|
|
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",
|
|
"external_link": "https://raclette.fr",
|
|
"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,
|
|
"external_link": "",
|
|
}
|
|
|
|
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", "<script>")
|
|
|
|
result = self.client.get("/raclette/")
|
|
self.assertNotIn("<script>", result.data.decode("utf-8"))
|
|
|
|
def test_weighted_bills(self):
|
|
# create a project
|
|
self.api_create("raclette")
|
|
|
|
# add members
|
|
self.api_add_member("raclette", "alexis")
|
|
self.api_add_member("raclette", "freddy familly", 4)
|
|
self.api_add_member("raclette", "arnaud")
|
|
|
|
# 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 this bill details
|
|
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()
|
|
|
|
# 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": "freddy familly", "weight": 4},
|
|
],
|
|
"amount": 25.0,
|
|
"date": "2011-08-10",
|
|
"id": 1,
|
|
"external_link": "",
|
|
}
|
|
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)
|
|
|
|
# getting it should return a 404
|
|
req = self.client.get(
|
|
"/api/projects/raclette", headers=self.get_auth("raclette")
|
|
)
|
|
|
|
expected = {
|
|
"members": [
|
|
{
|
|
"activated": True,
|
|
"id": 1,
|
|
"name": "alexis",
|
|
"weight": 1.0,
|
|
"balance": 20.0,
|
|
},
|
|
{
|
|
"activated": True,
|
|
"id": 2,
|
|
"name": "freddy familly",
|
|
"weight": 4.0,
|
|
"balance": -20.0,
|
|
},
|
|
{
|
|
"activated": True,
|
|
"id": 3,
|
|
"name": "arnaud",
|
|
"weight": 1.0,
|
|
"balance": 0,
|
|
},
|
|
],
|
|
"contact_email": "raclette@notmyidea.org",
|
|
"id": "raclette",
|
|
"name": "raclette",
|
|
"logging_preference": 1,
|
|
}
|
|
|
|
self.assertStatus(200, req)
|
|
decoded_req = json.loads(req.data.decode("utf-8"))
|
|
self.assertDictEqual(decoded_req, expected)
|
|
|
|
def test_log_created_from_api_call(self):
|
|
# create a project
|
|
self.api_create("raclette")
|
|
self.login("raclette")
|
|
|
|
# add members
|
|
self.api_add_member("raclette", "alexis")
|
|
|
|
resp = self.client.get("/raclette/history", follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
f"Participant {em_surround('alexis')} added", resp.data.decode("utf-8")
|
|
)
|
|
self.assertIn(
|
|
f"Project {em_surround('raclette')} added", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
|
|
|
|
class ServerTestCase(IhatemoneyTestCase):
|
|
def test_homepage(self):
|
|
# See https://github.com/spiral-project/ihatemoney/pull/358
|
|
self.app.config["APPLICATION_ROOT"] = "/"
|
|
req = self.client.get("/")
|
|
self.assertStatus(200, req)
|
|
|
|
def test_unprefixed(self):
|
|
self.app.config["APPLICATION_ROOT"] = "/"
|
|
req = self.client.get("/foo/")
|
|
self.assertStatus(303, req)
|
|
|
|
def test_prefixed(self):
|
|
self.app.config["APPLICATION_ROOT"] = "/foo"
|
|
req = self.client.get("/foo/")
|
|
self.assertStatus(200, req)
|
|
|
|
|
|
class CommandTestCase(BaseTestCase):
|
|
def test_generate_config(self):
|
|
""" Simply checks that all config file generation
|
|
- raise no exception
|
|
- produce something non-empty
|
|
"""
|
|
cmd = GenerateConfig()
|
|
for config_file in cmd.get_options()[0].kwargs["choices"]:
|
|
with patch("sys.stdout", new=io.StringIO()) as stdout:
|
|
cmd.run(config_file)
|
|
print(stdout.getvalue())
|
|
self.assertNotEqual(len(stdout.getvalue().strip()), 0)
|
|
|
|
def test_generate_password_hash(self):
|
|
cmd = GeneratePasswordHash()
|
|
with patch("sys.stdout", new=io.StringIO()) as stdout, patch(
|
|
"getpass.getpass", new=lambda prompt: "secret"
|
|
): # NOQA
|
|
cmd.run()
|
|
print(stdout.getvalue())
|
|
self.assertEqual(len(stdout.getvalue().strip()), 189)
|
|
|
|
def test_demo_project_deletion(self):
|
|
self.create_project("demo")
|
|
self.assertEquals(models.Project.query.get("demo").name, "demo")
|
|
|
|
cmd = DeleteProject()
|
|
cmd.run("demo")
|
|
|
|
self.assertEqual(len(models.Project.query.all()), 0)
|
|
|
|
|
|
class ModelsTestCase(IhatemoneyTestCase):
|
|
def test_bill_pay_each(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",
|
|
},
|
|
)
|
|
|
|
project = models.Project.query.get_by_name(name="raclette")
|
|
alexis = models.Person.query.get_by_name(name="alexis", project=project)
|
|
alexis_bills = models.Bill.query.options(
|
|
orm.subqueryload(models.Bill.owers)
|
|
).filter(models.Bill.owers.contains(alexis))
|
|
for bill in alexis_bills.all():
|
|
if bill.what == "red wine":
|
|
pay_each_expected = 20 / 2
|
|
self.assertEqual(bill.pay_each(), pay_each_expected)
|
|
if bill.what == "fromage à raclette":
|
|
pay_each_expected = 10 / 4
|
|
self.assertEqual(bill.pay_each(), pay_each_expected)
|
|
if bill.what == "delicatessen":
|
|
pay_each_expected = 10 / 3
|
|
self.assertEqual(bill.pay_each(), pay_each_expected)
|
|
|
|
|
|
def em_surround(string, regex_escape=False):
|
|
if regex_escape:
|
|
return r'<em class="font-italic">%s<\/em>' % string
|
|
else:
|
|
return '<em class="font-italic">%s</em>' % string
|
|
|
|
|
|
class HistoryTestCase(IhatemoneyTestCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.post_project("demo")
|
|
self.login("demo")
|
|
|
|
def test_simple_create_logentry_no_ip(self):
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
f"Project {em_surround('demo')} added", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
|
|
def change_privacy_to(self, logging_preference):
|
|
# Change only logging_preferences
|
|
new_data = {
|
|
"name": "demo",
|
|
"contact_email": "demo@notmyidea.org",
|
|
"password": "demo",
|
|
}
|
|
|
|
if logging_preference != LoggingMode.DISABLED:
|
|
new_data["project_history"] = "y"
|
|
if logging_preference == LoggingMode.RECORD_IP:
|
|
new_data["ip_recording"] = "y"
|
|
|
|
# Disable History
|
|
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertNotIn("danger", resp.data.decode("utf-8"))
|
|
|
|
resp = self.client.get("/demo/edit")
|
|
self.assertEqual(resp.status_code, 200)
|
|
if logging_preference == LoggingMode.DISABLED:
|
|
self.assertIn('<input id="project_history"', resp.data.decode("utf-8"))
|
|
else:
|
|
self.assertIn(
|
|
'<input checked id="project_history"', resp.data.decode("utf-8")
|
|
)
|
|
|
|
if logging_preference == LoggingMode.RECORD_IP:
|
|
self.assertIn('<input checked id="ip_recording"', resp.data.decode("utf-8"))
|
|
else:
|
|
self.assertIn('<input id="ip_recording"', resp.data.decode("utf-8"))
|
|
|
|
def assert_empty_history_logging_disabled(self):
|
|
resp = self.client.get("/demo/history")
|
|
self.assertIn(
|
|
"This project has history disabled. New actions won't appear below. ",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
"Nothing to list", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"The table below reflects actions recorded prior to disabling project history.",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"Some entries below contain IP addresses,", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
self.assertNotIn("<td> -- </td>", resp.data.decode("utf-8"))
|
|
self.assertNotIn(
|
|
f"Project {em_surround('demo')} added", resp.data.decode("utf-8")
|
|
)
|
|
|
|
def test_project_edit(self):
|
|
new_data = {
|
|
"name": "demo2",
|
|
"contact_email": "demo2@notmyidea.org",
|
|
"password": "123456",
|
|
"project_history": "y",
|
|
}
|
|
|
|
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(f"Project {em_surround('demo')} added", resp.data.decode("utf-8"))
|
|
self.assertIn(
|
|
f"Project contact email changed to {em_surround('demo2@notmyidea.org')}",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
"Project private code changed", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
f"Project renamed to {em_surround('demo2')}", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertLess(
|
|
resp.data.decode("utf-8").index("Project renamed "),
|
|
resp.data.decode("utf-8").index("Project contact email changed to "),
|
|
)
|
|
self.assertLess(
|
|
resp.data.decode("utf-8").index("Project renamed "),
|
|
resp.data.decode("utf-8").index("Project private code changed"),
|
|
)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 4)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
|
|
def test_project_privacy_edit(self):
|
|
resp = self.client.get("/demo/edit")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
'<input checked id="project_history" name="project_history" type="checkbox" value="y">',
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
|
|
self.change_privacy_to(LoggingMode.DISABLED)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn("Disabled Project History\n", resp.data.decode("utf-8"))
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
|
|
self.change_privacy_to(LoggingMode.RECORD_IP)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
"Enabled Project History & IP Address Recording", resp.data.decode("utf-8")
|
|
)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
|
|
|
|
self.change_privacy_to(LoggingMode.ENABLED)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn("Disabled IP Address Recording\n", resp.data.decode("utf-8"))
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
|
|
|
|
def test_project_privacy_edit2(self):
|
|
self.change_privacy_to(LoggingMode.RECORD_IP)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn("Enabled IP Address Recording\n", resp.data.decode("utf-8"))
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
|
|
|
|
self.change_privacy_to(LoggingMode.DISABLED)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
"Disabled Project History & IP Address Recording", resp.data.decode("utf-8")
|
|
)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
|
|
|
|
self.change_privacy_to(LoggingMode.ENABLED)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn("Enabled Project History\n", resp.data.decode("utf-8"))
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
|
|
|
|
def do_misc_database_operations(self, logging_mode):
|
|
new_data = {
|
|
"name": "demo2",
|
|
"contact_email": "demo2@notmyidea.org",
|
|
"password": "123456",
|
|
}
|
|
|
|
# Keep privacy settings where they were
|
|
if logging_mode != LoggingMode.DISABLED:
|
|
new_data["project_history"] = "y"
|
|
if logging_mode == LoggingMode.RECORD_IP:
|
|
new_data["ip_recording"] = "y"
|
|
|
|
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
# adds a member to this project
|
|
resp = self.client.post(
|
|
"/demo/members/add", data={"name": "alexis"}, follow_redirects=True
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
user_id = models.Person.query.one().id
|
|
|
|
# create a bill
|
|
resp = self.client.post(
|
|
"/demo/add",
|
|
data={
|
|
"date": "2011-08-10",
|
|
"what": "fromage à raclette",
|
|
"payer": user_id,
|
|
"payed_for": [user_id],
|
|
"amount": "25",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
bill_id = models.Bill.query.one().id
|
|
|
|
# edit the bill
|
|
resp = self.client.post(
|
|
f"/demo/edit/{bill_id}",
|
|
data={
|
|
"date": "2011-08-10",
|
|
"what": "fromage à raclette",
|
|
"payer": user_id,
|
|
"payed_for": [user_id],
|
|
"amount": "10",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
# delete the bill
|
|
resp = self.client.get(f"/demo/delete/{bill_id}", follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
# delete user using POST method
|
|
resp = self.client.post(
|
|
f"/demo/members/{user_id}/delete", follow_redirects=True
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_disable_clear_no_new_records(self):
|
|
# Disable logging
|
|
self.change_privacy_to(LoggingMode.DISABLED)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
"This project has history disabled. New actions won't appear below. ",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
"The table below reflects actions recorded prior to disabling project history.",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"Nothing to list", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"Some entries below contain IP addresses,", resp.data.decode("utf-8"),
|
|
)
|
|
|
|
# Clear Existing Entries
|
|
resp = self.client.post("/demo/erase_history", follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assert_empty_history_logging_disabled()
|
|
|
|
# Do lots of database operations & check that there's still no history
|
|
self.do_misc_database_operations(LoggingMode.DISABLED)
|
|
|
|
self.assert_empty_history_logging_disabled()
|
|
|
|
def test_clear_ip_records(self):
|
|
# Enable IP Recording
|
|
self.change_privacy_to(LoggingMode.RECORD_IP)
|
|
|
|
# Do lots of database operations to generate IP address entries
|
|
self.do_misc_database_operations(LoggingMode.RECORD_IP)
|
|
|
|
# Disable IP Recording
|
|
self.change_privacy_to(LoggingMode.ENABLED)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertNotIn(
|
|
"This project has history disabled. New actions won't appear below. ",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"The table below reflects actions recorded prior to disabling project history.",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"Nothing to list", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertIn(
|
|
"Some entries below contain IP addresses,", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 10)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
|
|
|
|
# Generate more operations to confirm additional IP info isn't recorded
|
|
self.do_misc_database_operations(LoggingMode.ENABLED)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 10)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
|
|
|
|
# Clear IP Data
|
|
resp = self.client.post("/demo/strip_ip_addresses", follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertNotIn(
|
|
"This project has history disabled. New actions won't appear below. ",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"The table below reflects actions recorded prior to disabling project history.",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"Nothing to list", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertNotIn(
|
|
"Some entries below contain IP addresses,", resp.data.decode("utf-8"),
|
|
)
|
|
self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 0)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 16)
|
|
|
|
def test_logs_for_common_actions(self):
|
|
# adds a member to this project
|
|
resp = self.client.post(
|
|
"/demo/members/add", data={"name": "alexis"}, follow_redirects=True
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
f"Participant {em_surround('alexis')} added", resp.data.decode("utf-8")
|
|
)
|
|
|
|
# create a bill
|
|
resp = self.client.post(
|
|
"/demo/add",
|
|
data={
|
|
"date": "2011-08-10",
|
|
"what": "fromage à raclette",
|
|
"payer": 1,
|
|
"payed_for": [1],
|
|
"amount": "25",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
f"Bill {em_surround('fromage à raclette')} added",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
|
|
# edit the bill
|
|
resp = self.client.post(
|
|
"/demo/edit/1",
|
|
data={
|
|
"date": "2011-08-10",
|
|
"what": "new thing",
|
|
"payer": 1,
|
|
"payed_for": [1],
|
|
"amount": "10",
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
f"Bill {em_surround('fromage à raclette')} added",
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertRegex(
|
|
resp.data.decode("utf-8"),
|
|
r"Bill %s:\s* Amount changed\s* from %s\s* to %s"
|
|
% (
|
|
em_surround("fromage à raclette", regex_escape=True),
|
|
em_surround("25.0", regex_escape=True),
|
|
em_surround("10.0", regex_escape=True),
|
|
),
|
|
)
|
|
self.assertIn(
|
|
"Bill %s renamed to %s"
|
|
% (em_surround("fromage à raclette"), em_surround("new thing"),),
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertLess(
|
|
resp.data.decode("utf-8").index(
|
|
f"Bill {em_surround('fromage à raclette')} renamed to"
|
|
),
|
|
resp.data.decode("utf-8").index("Amount changed"),
|
|
)
|
|
|
|
# delete the bill
|
|
resp = self.client.get("/demo/delete/1", follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
f"Bill {em_surround('new thing')} removed", resp.data.decode("utf-8"),
|
|
)
|
|
|
|
# edit user
|
|
resp = self.client.post(
|
|
"/demo/members/1/edit",
|
|
data={"weight": 2, "name": "new name"},
|
|
follow_redirects=True,
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertRegex(
|
|
resp.data.decode("utf-8"),
|
|
r"Participant %s:\s* Weight changed\s* from %s\s* to %s"
|
|
% (
|
|
em_surround("alexis", regex_escape=True),
|
|
em_surround("1.0", regex_escape=True),
|
|
em_surround("2.0", regex_escape=True),
|
|
),
|
|
)
|
|
self.assertIn(
|
|
"Participant %s renamed to %s"
|
|
% (em_surround("alexis"), em_surround("new name"),),
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
self.assertLess(
|
|
resp.data.decode("utf-8").index(
|
|
f"Participant {em_surround('alexis')} renamed"
|
|
),
|
|
resp.data.decode("utf-8").index("Weight changed"),
|
|
)
|
|
|
|
# delete user using POST method
|
|
resp = self.client.post("/demo/members/1/delete", follow_redirects=True)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertIn(
|
|
f"Participant {em_surround('new name')} removed", resp.data.decode("utf-8")
|
|
)
|
|
|
|
def test_double_bill_double_person_edit_second(self):
|
|
|
|
# add two members
|
|
self.client.post("/demo/members/add", data={"name": "User 1"})
|
|
self.client.post("/demo/members/add", data={"name": "User 2"})
|
|
|
|
# add two bills
|
|
self.client.post(
|
|
"/demo/add",
|
|
data={
|
|
"date": "2020-04-13",
|
|
"what": "Bill 1",
|
|
"payer": 1,
|
|
"payed_for": [1, 2],
|
|
"amount": "25",
|
|
},
|
|
)
|
|
self.client.post(
|
|
"/demo/add",
|
|
data={
|
|
"date": "2020-04-13",
|
|
"what": "Bill 2",
|
|
"payer": 1,
|
|
"payed_for": [1, 2],
|
|
"amount": "20",
|
|
},
|
|
)
|
|
|
|
# Should be 5 history entries at this point
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 5)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
|
|
# Edit ONLY the amount on the first bill
|
|
self.client.post(
|
|
"/demo/edit/1",
|
|
data={
|
|
"date": "2020-04-13",
|
|
"what": "Bill 1",
|
|
"payer": 1,
|
|
"payed_for": [1, 2],
|
|
"amount": "88",
|
|
},
|
|
)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertRegex(
|
|
resp.data.decode("utf-8"),
|
|
r"Bill {}:\s* Amount changed\s* from {}\s* to {}".format(
|
|
em_surround("Bill 1", regex_escape=True),
|
|
em_surround("25.0", regex_escape=True),
|
|
em_surround("88.0", regex_escape=True),
|
|
),
|
|
)
|
|
|
|
self.assertNotRegex(
|
|
resp.data.decode("utf-8"),
|
|
r"Removed\s* {}\s* and\s* {}\s* from\s* owers list".format(
|
|
em_surround("User 1", regex_escape=True),
|
|
em_surround("User 2", regex_escape=True),
|
|
),
|
|
resp.data.decode("utf-8"),
|
|
)
|
|
|
|
# Should be 6 history entries at this point
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
|
|
def test_bill_add_remove_add(self):
|
|
# add two members
|
|
self.client.post("/demo/members/add", data={"name": "User 1"})
|
|
self.client.post("/demo/members/add", data={"name": "User 2"})
|
|
|
|
# add 1 bill
|
|
self.client.post(
|
|
"/demo/add",
|
|
data={
|
|
"date": "2020-04-13",
|
|
"what": "Bill 1",
|
|
"payer": 1,
|
|
"payed_for": [1, 2],
|
|
"amount": "25",
|
|
},
|
|
)
|
|
|
|
# delete the bill
|
|
self.client.get("/demo/delete/1", follow_redirects=True)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 5)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
self.assertIn(f"Bill {em_surround('Bill 1')} added", resp.data.decode("utf-8"))
|
|
self.assertIn(
|
|
f"Bill {em_surround('Bill 1')} removed", resp.data.decode("utf-8"),
|
|
)
|
|
|
|
# Add a new bill
|
|
self.client.post(
|
|
"/demo/add",
|
|
data={
|
|
"date": "2020-04-13",
|
|
"what": "Bill 2",
|
|
"payer": 1,
|
|
"payed_for": [1, 2],
|
|
"amount": "20",
|
|
},
|
|
)
|
|
|
|
resp = self.client.get("/demo/history")
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
|
|
self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
|
|
self.assertIn(f"Bill {em_surround('Bill 1')} added", resp.data.decode("utf-8"))
|
|
self.assertEqual(
|
|
resp.data.decode("utf-8").count(f"Bill {em_surround('Bill 1')} added"), 1,
|
|
)
|
|
self.assertIn(f"Bill {em_surround('Bill 2')} added", resp.data.decode("utf-8"))
|
|
self.assertIn(
|
|
f"Bill {em_surround('Bill 1')} removed", resp.data.decode("utf-8"),
|
|
)
|
|
|
|
def test_double_bill_double_person_edit_second_no_web(self):
|
|
u1 = models.Person(project_id="demo", name="User 1")
|
|
u2 = models.Person(project_id="demo", name="User 1")
|
|
|
|
models.db.session.add(u1)
|
|
models.db.session.add(u2)
|
|
models.db.session.commit()
|
|
|
|
b1 = models.Bill(what="Bill 1", payer_id=u1.id, owers=[u2], amount=10,)
|
|
b2 = models.Bill(what="Bill 2", payer_id=u2.id, owers=[u2], amount=11,)
|
|
|
|
# This db commit exposes the "spurious owers edit" bug
|
|
models.db.session.add(b1)
|
|
models.db.session.commit()
|
|
|
|
models.db.session.add(b2)
|
|
models.db.session.commit()
|
|
|
|
history_list = history.get_history(models.Project.query.get("demo"))
|
|
self.assertEqual(len(history_list), 5)
|
|
|
|
# Change just the amount
|
|
b1.amount = 5
|
|
models.db.session.commit()
|
|
|
|
history_list = history.get_history(models.Project.query.get("demo"))
|
|
for entry in history_list:
|
|
if "prop_changed" in entry:
|
|
self.assertNotIn("owers", entry["prop_changed"])
|
|
self.assertEqual(len(history_list), 6)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|