Unzip budget-app v7 into repo root

This commit is contained in:
jpmvaz
2026-10-29 20:40:02 +00:00
parent 2554e68315
commit c9fd26f1c7
94 changed files with 12098 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import os
import sys
from pathlib import Path
import pytest
DB = Path(__file__).parent / "test.db"
os.environ["DATABASE_URL"] = f"sqlite:///{DB}"
os.environ["ADMIN_EMAIL"] = "admin@test.local"
os.environ["ADMIN_PASSWORD"] = "admin-pass-123"
sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi.testclient import TestClient # noqa: E402
from app.bootstrap import bootstrap # noqa: E402
from app.db import Base, engine # noqa: E402
from app.main import app # noqa: E402
FIXTURE = Path(__file__).parent / "fixtures" / "budget_2027.xlsx"
H = {"X-Requested-With": "budget-app"}
@pytest.fixture()
def client():
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
bootstrap()
with TestClient(app) as c:
r = c.post("/api/auth/login", json={"email": "admin@test.local", "password": "admin-pass-123"}, headers=H)
assert r.status_code == 200
c.headers.update(H)
yield c
@pytest.fixture()
def imported(client):
with FIXTURE.open("rb") as f:
r = client.post("/api/years/import", files={"file": ("b.xlsx", f, "application/octet-stream")})
assert r.status_code == 200, r.text
return r.json()["year"]["id"]
Binary file not shown.
+113
View File
@@ -0,0 +1,113 @@
from io import BytesIO
import openpyxl
import pytest
# Category totals from the "Capex e Custos 2027 v4" PDF. Lines are rounded to cents before
# summing, so a category can differ from the PDF by a cent or two.
PDF_TOTALS = {
"Contract Services": (600758.54, 615940.02),
"Cost of Cell Phones": (96532.00, 111011.80),
"Cost of Internet Services": (74160.00, 85284.00),
"Cost of Local Calls": (5575.00, 6269.13),
"Cost of Long Distance Calls": (11415.00, 12488.47),
"Other Cost of Services": (176847.00, 185226.36),
"Hardware": (37636.00, 83361.00),
"Property Operation and Maintenance": (31283.00, 55015.83),
"Other IT Equipment": (72566.00, 70747.24),
"Travel-Other": (14400.00, 16880.00),
}
def test_requires_login():
from fastapi.testclient import TestClient
from app.main import app
assert TestClient(app).get("/api/years").status_code == 401
def test_writes_need_header(client):
r = client.post("/api/years", json={"year": 2030}, headers={"X-Requested-With": ""})
assert r.status_code == 403
def test_import_matches_pdf(client, imported):
y = client.get(f"/api/years/{imported}").json()
assert y["year"] == 2027 and y["default_rate"] == pytest.approx(0.055)
got = {c["category"]["name_en"]: c["totals"] for c in y["categories"] if c["lines"]}
for name, (prior, adjusted) in PDF_TOTALS.items():
assert got[name]["prior"] == pytest.approx(prior, abs=0.005)
assert got[name]["adjusted"] == pytest.approx(adjusted, abs=0.02)
assert y["costs"]["prior"] == pytest.approx(1121172.54, abs=0.005)
assert y["costs"]["adjusted"] == pytest.approx(1242223.85, abs=0.05)
assert sum(len(c["lines"]) for c in y["categories"]) == 54
hw = next(c for c in y["categories"] if c["category"]["name_en"] == "Hardware")
oriente = next(l for l in hw["lines"] if "ORIENTE" in l["name"])
assert oriente["adj_kind"] == "factor" and oriente["adj_value"] == pytest.approx(3.055)
assert "Hardware price increase" in oriente["adj_reason"]
protel = next(l for c in y["categories"] for l in c["lines"] if l["name"] == "Protel")
assert protel["prior_expr"] == "5512*12"
def test_line_crud_and_month_spread(client, imported):
cats = client.get("/api/categories").json()
training = next(c for c in cats if c["name_en"] == "Training")
r = client.post(f"/api/years/{imported}/lines", json={
"category_id": training["id"], "name": "Microsoft certification", "prior_expr": "1000",
"base_expr": "=4*300", "adj_kind": "percent", "adj_value": 10})
assert r.status_code == 200, r.text
line_id = r.json()["id"]
y = client.get(f"/api/years/{imported}").json()
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
assert line["base"] == 1200 and line["adjusted"] == 1320 and sum(line["months"]) == pytest.approx(1320)
assert client.put(f"/api/lines/{line_id}", json={"base_expr": "abc"}).status_code == 400
r = client.put(f"/api/lines/{line_id}", json={"monthly_kind": "manual"})
assert r.status_code == 200
y = client.get(f"/api/years/{imported}").json()
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
assert line["monthly_values"][0] == 110 and line["adjusted"] == pytest.approx(1320)
assert client.delete(f"/api/lines/{line_id}").status_code == 200
audit = client.get(f"/api/years/{imported}/audit").json()
assert [a["action"] for a in audit[:3]] == ["delete", "update", "create"]
def test_status_workflow_and_roles(client, imported):
it = client.get("/api/departments").json()[0]["id"]
r = client.post("/api/users", json={"email": "ed@test.local", "name": "Ed", "role": "editor",
"password": "editor-pass-1", "department_id": it})
assert r.status_code == 200
from fastapi.testclient import TestClient
from app.main import app
ed = TestClient(app)
ed.headers.update({"X-Requested-With": "budget-app"})
assert ed.post("/api/auth/login", json={"email": "ed@test.local", "password": "editor-pass-1"}).status_code == 200
assert ed.get("/api/users").status_code == 403
assert ed.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
assert ed.post(f"/api/years/{imported}/status", json={"action": "approve"}).status_code == 403
line = client.get(f"/api/years/{imported}").json()["categories"][1]["lines"][0]
assert ed.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 409
assert client.post(f"/api/years/{imported}/status", json={"action": "approve"}).json()["status"] == "approved"
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen"}).status_code == 400 # reason needed
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen", "comment": "new contract"}).json()["status"] == "draft"
def test_rollover(client, imported):
r = client.post("/api/years", json={"year": 2028, "copy_from_id": imported})
assert r.status_code == 200
y = client.get(f"/api/years/{r.json()['id']}").json()
assert y["costs"]["prior"] == pytest.approx(1242223.85, abs=0.05)
@pytest.mark.parametrize("kind", ["cost-report", "budget"])
@pytest.mark.parametrize("lang", ["pt", "en"])
def test_reports(client, imported, kind, lang):
html = client.get(f"/api/years/{imported}/reports/{kind}.html?lang={lang}")
assert html.status_code == 200
assert ("<svg" if kind == "cost-report" else "<table") in html.text
xlsx = client.get(f"/api/years/{imported}/reports/{kind}.xlsx?lang={lang}")
assert xlsx.status_code == 200
wb = openpyxl.load_workbook(BytesIO(xlsx.content))
assert len(wb.sheetnames) >= 2
pdf = client.get(f"/api/years/{imported}/reports/{kind}.pdf?lang={lang}")
assert pdf.status_code == 200 and pdf.content[:4] == b"%PDF"
+30
View File
@@ -0,0 +1,30 @@
from decimal import Decimal
import pytest
from app.calc import ExpressionError, evaluate, even_spread
@pytest.mark.parametrize("text,expected", [
("=5512*12", "66144"),
("3292.56+(12*839.67)+(12*148.19)", "15146.88"),
("4 839,65", "4839.65"),
("1.234,50", "1234.50"),
("1,234.50", "1234.50"),
("", "0"),
("-10/4", "-2.5"),
])
def test_evaluate(text, expected):
assert evaluate(text) == Decimal(expected)
@pytest.mark.parametrize("text", ["__import__('os')", "2**10", "1/0", "abc", "1;2"])
def test_evaluate_rejects(text):
with pytest.raises(ExpressionError):
evaluate(text)
def test_even_spread_adds_up():
months = even_spread(Decimal("100.00"))
assert sum(months) == Decimal("100.00")
assert months[0] == Decimal("8.33") and months[-1] == Decimal("8.37")
@@ -0,0 +1,127 @@
from io import BytesIO
import openpyxl
import pytest
from fastapi.testclient import TestClient
from app.main import app
H = {"X-Requested-With": "budget-app"}
def login(email, password):
c = TestClient(app)
c.headers.update(H)
assert c.post("/api/auth/login", json={"email": email, "password": password}).status_code == 200
return c
@pytest.fixture()
def org(client, imported):
"""IT (with the imported 2027 budget) and F&B, one editor and one approver in each."""
it = client.get("/api/departments").json()[0]
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
users = {}
for key, role, dept in (("it_ed", "editor", it), ("it_ap", "approver", it),
("fb_ed", "editor", fb), ("fb_ap", "approver", fb)):
r = client.post("/api/users", json={"email": f"{key}@t.local", "name": key, "role": role,
"password": "password-123", "department_id": dept["id"]})
assert r.status_code == 200, r.text
users[key] = login(f"{key}@t.local", "password-123")
return {"it": it, "fb": fb, "year": imported, **users}
def test_categories_are_per_department(client, org):
it_cats = client.get(f"/api/categories?department_id={org['it']['id']}").json()
fb_cats = client.get(f"/api/categories?department_id={org['fb']['id']}").json()
assert len(it_cats) == len(fb_cats) == 50
assert not {c["id"] for c in it_cats} & {c["id"] for c in fb_cats}
# a line in an IT budget cannot use an F&B category
r = client.post(f"/api/years/{org['year']}/lines", json={"category_id": fb_cats[0]["id"], "name": "x"})
assert r.status_code == 400 and r.json()["detail"] == "invalid_category"
# the same year can exist once per department
r = org["fb_ed"].post("/api/years", json={"year": 2027})
assert r.status_code == 200 and r.json()["department"] == "F&B"
def test_other_departments_can_only_view(org):
fb = org["fb_ed"]
y = fb.get(f"/api/years/{org['year']}").json()
assert y["department"] == "IT" and y["actions"]["edit"] is False
line = y["categories"][1]["lines"][0]
assert fb.put(f"/api/lines/{line['id']}", json={"name": "hack"}).status_code == 403
assert fb.post(f"/api/years/{org['year']}/status", json={"action": "submit"}).status_code == 403
assert fb.get(f"/api/years/{org['year']}/reports/cost-report.xlsx").status_code == 200
assert org["it_ed"].put(f"/api/lines/{line['id']}", json={"remarks": "ok"}).status_code == 200
def test_custom_workflow(client, org):
it = org["it"]["id"]
users = {u["email"]: u["id"] for u in client.get("/api/users").json()}
r = client.put(f"/api/departments/{it}/workflow", json={"steps": [
{"name": "Department head", "approver_mode": "role"},
{"name": "Finance", "approver_mode": "users", "approver_ids": [users["fb_ap@t.local"]]},
]})
assert r.status_code == 200 and [s["name"] for s in r.json()["steps"]] == ["Department head", "Finance"]
y = org["year"]
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"}).json()["current_step"] == 0
# step 1: only IT approvers; the F&B approver is not on this step
assert org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
r = org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
assert r.json()["status"] == "in_review" and r.json()["current_step"] == 1
# step 2: only the named user
assert org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
r = org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
assert r.json()["status"] == "approved"
# the workflow cannot be changed while a budget is in review
org["it_ap"].post(f"/api/years/{y}/status", json={"action": "reopen", "comment": "fix a line"})
org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"})
assert client.put(f"/api/departments/{it}/workflow", json={"steps": []}).status_code == 409
def test_template_roundtrip(client, org):
fb = org["fb"]["id"]
r = org["fb_ed"].get(f"/api/departments/{fb}/template.xlsx?lang=pt&year=2028&rate=3")
assert r.status_code == 200
wb = openpyxl.load_workbook(BytesIO(r.content))
assert wb.sheetnames == ["Instruções", "Linhas", "Listas"]
ws = wb["Linhas"]
rows = [
["Formação", "Curso de barista", "", "Chiado", 1000, "=4*300", "Percentagem", 10, "", "nota"],
["Vinhos", "Carta de vinhos", "", "", 5000, 5000, "Fator", 1.2, "Preços", ""],
["Receitas", "Eventos", "", "Grupo", 0, None, None, None, "", ""] + [100] * 12,
["Nova categoria", "Algo novo", "", "Lua", 10, 10, "", None, "", ""],
]
for i, row in enumerate(rows):
for j, v in enumerate(row):
ws.cell(9 + i, j + 1, v)
buf = BytesIO()
wb.save(buf)
r = org["fb_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
data={"department_id": str(fb)})
assert r.status_code == 200, r.text
res = r.json()
assert res["lines"] == 4 and res["categories_created"] == ["Nova categoria"]
assert any("Lua" in w for w in res["warnings"])
y = client.get(f"/api/years/{res['year']['id']}").json()
assert y["year"] == 2028 and y["default_rate"] == pytest.approx(0.03) and y["department"] == "F&B"
lines = {l["name"]: l for c in y["categories"] for l in c["lines"]}
assert lines["Curso de barista"]["adjusted"] == 1320
assert lines["Carta de vinhos"]["adjusted"] == 6000
assert lines["Eventos"]["adjusted"] == 1200 and lines["Eventos"]["monthly_kind"] == "manual"
assert lines["Algo novo"]["adjusted"] == pytest.approx(10.3)
# the IT editor cannot import into F&B
r = org["it_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
data={"department_id": str(fb), "replace": "true"})
assert r.status_code == 403
def test_prefilled_template_reimports_same_totals(client, org):
it = org["it"]["id"]
r = client.get(f"/api/departments/{it}/template.xlsx?lang=en&from_year_id={org['year']}&year=2029&rate=5.5")
res = client.post("/api/years/import", files={"file": ("t.xlsx", r.content)}, data={"department_id": str(it)}).json()
assert res["lines"] == 54
a = client.get(f"/api/years/{org['year']}").json()["costs"]
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
assert a["adjusted"] == pytest.approx(b["adjusted"], abs=0.01) and a["prior"] == b["prior"]
+191
View File
@@ -0,0 +1,191 @@
import email
import pytest
from fastapi.testclient import TestClient
from app import mailer
from app.main import app
H = {"X-Requested-With": "budget-app"}
class FakeSMTP:
sent: list = []
fail_login = False
def __init__(self, host, port, timeout=None, context=None):
self.host, self.port = host, port
def ehlo(self):
pass
def starttls(self, context=None):
pass
def login(self, user, password):
if FakeSMTP.fail_login or password != "smtp-secret":
import smtplib
raise smtplib.SMTPAuthenticationError(535, b"bad credentials")
def send_message(self, msg, from_addr=None, to_addrs=None):
FakeSMTP.sent.append((from_addr, list(to_addrs), msg))
return {}
def quit(self):
pass
class FakeIMAP:
appended: list = []
folders = [b'(\\HasNoChildren) "/" "INBOX"', b'(\\HasNoChildren \\Sent) "/" "Itens Enviados"',
b'(\\HasNoChildren) "/" "Sent"']
def __init__(self, host, port, ssl_context=None, timeout=None):
pass
def login(self, user, password):
assert password == "smtp-secret"
def list(self):
return "OK", list(FakeIMAP.folders)
def append(self, folder, flags, date, data):
FakeIMAP.appended.append((folder, flags, data))
return "OK", [b"APPEND completed"]
def logout(self):
pass
@pytest.fixture()
def fake_mail(monkeypatch):
FakeSMTP.sent, FakeIMAP.appended, FakeSMTP.fail_login = [], [], False
monkeypatch.setattr(mailer.smtplib, "SMTP", FakeSMTP)
monkeypatch.setattr(mailer.smtplib, "SMTP_SSL", FakeSMTP)
monkeypatch.setattr(mailer.imaplib, "IMAP4_SSL", FakeIMAP)
monkeypatch.setattr(mailer.imaplib, "IMAP4", FakeIMAP)
def configure(client, **extra):
body = {"enabled": True, "smtp_host": "mail.local", "smtp_port": 587, "smtp_security": "starttls",
"smtp_username": "budget@local.test", "smtp_password": "smtp-secret",
"from_address": "budget@local.test", "from_name": "Budget", "save_to_sent": True,
"imap_host": "mail.local", "imap_port": 993, "imap_security": "ssl", **extra}
r = client.put("/api/mail/settings", json=body)
assert r.status_code == 200, r.text
return r.json()
def test_settings_hide_and_encrypt_passwords(client, fake_mail):
out = configure(client)
assert out["smtp_password_set"] and "smtp_password" not in out and out["ready"]
from app.db import SessionLocal
from app.models import MailSettings
with SessionLocal() as db:
stored = db.get(MailSettings, 1).smtp_password
assert stored and "smtp-secret" not in stored and mailer.decrypt(stored) == "smtp-secret"
# saving again without a password keeps it
configure(client, smtp_password="")
assert client.post("/api/mail/test", json={}).json() == {"smtp": "ok", "imap": "ok", "sent_folder": "Itens Enviados"}
def test_send_report_saves_sent_copy_and_logs(client, imported, fake_mail):
configure(client)
r = client.post(f"/api/years/{imported}/email", json={
"to": ["cfo@example.com; ops@example.com"], "cc": ["boss@example.com"], "subject": "Budget 2027",
"message": "Segue o orçamento.", "lang": "pt", "copy_me": True,
"reports": [{"kind": "cost-report", "fmt": "pdf"}, {"kind": "budget", "fmt": "xlsx"}]})
assert r.status_code == 200, r.text
log = r.json()
assert log["status"] == "sent" and log["sent_copy"] == "saved" and log["sent_copy_detail"] == "Itens Enviados"
assert log["to"] == ["cfo@example.com", "ops@example.com"]
assert log["cc"] == ["boss@example.com", "admin@test.local"]
sender, rcpts, msg = FakeSMTP.sent[0]
assert sender == "budget@local.test" and len(rcpts) == 4
names = [p.get_filename() for p in msg.iter_attachments()]
assert names == ["Budget_IT_2027.xlsx", "Capex_e_Custos_IT_2027.pdf"]
folder, flags, data = FakeIMAP.appended[0]
assert folder == '"Itens Enviados"' and flags == r"(\Seen)"
copy = email.message_from_bytes(data)
assert copy["Message-ID"] == msg["Message-ID"] and copy["Subject"] == "Budget 2027"
rows = client.get("/api/mail/log").json()
assert rows["total"] == 1 and rows["rows"][0]["user"] == "Administrator"
assert [a["name"] for a in rows["rows"][0]["attachments"]] == names
assert "cfo@example.com" in client.get("/api/mail/log.csv").text
acts = client.get("/api/activity?entity=mail").json()["rows"]
assert acts[0]["action"] == "send" and "cfo@example.com" in acts[0]["summary"]
def test_failed_send_is_logged(client, imported, fake_mail):
configure(client)
FakeSMTP.fail_login = True
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
"reports": [{"kind": "budget", "fmt": "pdf"}]})
assert r.status_code == 502 and r.json()["detail"] == "smtp_auth"
row = client.get("/api/mail/log?status=failed").json()["rows"][0]
assert row["error"].startswith("smtp_auth") and row["sent_copy"] == "off"
assert not FakeIMAP.appended
def test_sent_folder_found_by_name(fake_mail, monkeypatch):
monkeypatch.setattr(FakeIMAP, "folders", [b'(\\HasNoChildren) "." "INBOX"', b'(\\HasNoChildren) "." "INBOX.Sent"'])
assert mailer.find_sent_folder(FakeIMAP("h", 1), "") == "INBOX.Sent"
with pytest.raises(mailer.MailError) as exc:
mailer.find_sent_folder(FakeIMAP("h", 1), "Outbox")
assert exc.value.code == "sent_folder_not_found"
def test_mail_rules(client, imported, fake_mail):
# not configured yet
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
"reports": [{"kind": "budget", "fmt": "pdf"}]})
assert r.status_code == 409
configure(client)
bad = client.post(f"/api/years/{imported}/email", json={"to": ["not-an-address"],
"reports": [{"kind": "budget", "fmt": "pdf"}]})
assert bad.status_code == 400 and bad.json()["detail"].startswith("invalid_address")
assert client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"], "reports": []}).status_code == 400
client.post("/api/users", json={"email": "v@t.local", "name": "Viewer", "role": "viewer",
"password": "password-123"})
viewer = TestClient(app)
viewer.headers.update(H)
viewer.post("/api/auth/login", json={"email": "v@t.local", "password": "password-123"})
# any signed-in user may email a report; only admins see the settings and the logs
assert viewer.get("/api/mail/status").json()["enabled"] is True
assert viewer.post(f"/api/years/{imported}/email", json={
"to": ["a@example.com"], "reports": [{"kind": "budget", "fmt": "xlsx"}]}).status_code == 200
for url in ("/api/mail/settings", "/api/mail/log", "/api/mail/log.csv", "/api/activity", "/api/activity.csv"):
assert viewer.get(url).status_code == 403, url
assert viewer.put("/api/mail/settings", json={"enabled": False}).status_code == 403
def test_only_admin_deletes_budgets(client, imported):
client.post("/api/users", json={"email": "ap@t.local", "name": "Approver", "role": "approver",
"password": "password-123", "department_id": 1})
ap = TestClient(app)
ap.headers.update(H)
ap.post("/api/auth/login", json={"email": "ap@t.local", "password": "password-123"})
assert ap.get(f"/api/years/{imported}").json()["actions"]["delete"] is False
assert ap.delete(f"/api/years/{imported}").status_code == 403
# an admin can delete it even after approval
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
assert client.get(f"/api/years/{imported}").json()["actions"]["delete"] is True
assert client.delete(f"/api/years/{imported}").status_code == 200
assert client.get(f"/api/years/{imported}").status_code == 404
row = client.get("/api/activity?action=delete&entity=year").json()["rows"][0]
assert row["user"] == "Administrator" and "IT 2027" in row["summary"] and row["budget_exists"] is False
def test_activity_log_records_who_does_what(client, imported):
client.post("/api/auth/login", json={"email": "admin@test.local", "password": "wrong"})
client.get(f"/api/years/{imported}/reports/budget.xlsx")
data = client.get("/api/activity").json()
actions = [(r["entity"], r["action"]) for r in data["rows"]]
assert ("report", "download") in actions and ("user", "login_failed") in actions and ("year", "import") in actions
assert data["rows"][0]["budget"] == "IT 2027" or data["rows"][0]["entity"] == "user"
only = client.get("/api/activity?entity=report").json()
assert only["total"] == 1 and only["rows"][0]["budget"] == "IT 2027"
assert "budget.xlsx" in client.get("/api/activity.csv?entity=report").text
+71
View File
@@ -0,0 +1,71 @@
from decimal import Decimal
from io import BytesIO
import openpyxl
from app.calc import selected_spread
def test_selected_spread_in_cents():
months = selected_spread(Decimal("1000.00"), [1, 2, 3])
assert months[:3] == [Decimal("333.33"), Decimal("333.33"), Decimal("333.34")]
assert sum(months) == Decimal("1000.00") and months[3:] == [0] * 9
assert selected_spread(Decimal("120"), [4, 1, 3, 2, 2]) == [30, 30, 30, 30] + [0] * 8
assert selected_spread(Decimal("12"), []) == [1] * 12
def _first_line(client, year_id):
return client.get(f"/api/years/{year_id}").json()["categories"][0]["lines"][0]
def test_line_split_over_chosen_months(client, imported):
line = _first_line(client, imported)
r = client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [4, 1, 2, 3, 3]})
assert r.status_code == 200, r.text
after = _first_line(client, imported)
assert after["monthly_kind"] == "selected" and after["monthly_months"] == [1, 2, 3, 4]
assert after["adjusted"] == line["adjusted"] # the year total does not change, only when it is spent
share = round(line["adjusted"] / 4, 2)
assert after["months"][0] == share and after["months"][4:] == [0] * 8
assert round(sum(after["months"]), 2) == line["adjusted"]
# the year's monthly totals follow; the monthly budget report shows the months
year = client.get(f"/api/years/{imported}").json()
assert year["costs"]["adjusted"] == client.get(f"/api/years/{imported}").json()["costs"]["adjusted"]
assert client.get(f"/api/years/{imported}/reports/budget.xlsx").status_code == 200
assert client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": []}).status_code == 400
assert client.put(f"/api/lines/{line['id']}", json={"monthly_months": [13]}).status_code == 400
# rolling over to next year keeps the chosen months
nxt = client.post("/api/years", json={"year": 2028, "copy_from_id": imported}).json()
copied = _first_line(client, nxt["id"])
assert copied["monthly_kind"] == "selected" and copied["monthly_months"] == [1, 2, 3, 4]
# back to 12 months
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "even", "monthly_months": None})
assert _first_line(client, imported)["months"][11] != 0
def test_template_keeps_chosen_months(client, imported):
line = _first_line(client, imported)
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [1, 2, 3, 4, 7]})
dept = client.get("/api/departments").json()[0]["id"]
r = client.get(f"/api/departments/{dept}/template.xlsx?lang=pt&from_year_id={imported}&year=2030")
ws = openpyxl.load_workbook(BytesIO(r.content))["Linhas"]
assert ws.cell(8, 23).value == "Dividir pelos meses"
assert "1-4, 7" in [ws.cell(row, 23).value for row in range(9, 70)]
# a hand-written month list on a row without typed months
free = next(r for r in range(9, 70) if ws.cell(r, 2).value and ws.cell(r, 23).value is None
and all(ws.cell(r, c).value is None for c in range(11, 23)))
ws.cell(free, 23, "Set-Dez")
buf = BytesIO()
ws.parent.save(buf)
res = client.post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
data={"department_id": str(dept)}).json()
lines = [ln for c in client.get(f"/api/years/{res['year']['id']}").json()["categories"] for ln in c["lines"]]
picked = [ln["monthly_months"] for ln in lines if ln["monthly_kind"] == "selected"]
assert [1, 2, 3, 4, 7] in picked and [9, 10, 11, 12] in picked
a = client.get(f"/api/years/{imported}").json()["costs"]
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
assert abs(a["adjusted"] - b["adjusted"]) < 0.01
+127
View File
@@ -0,0 +1,127 @@
from fastapi.testclient import TestClient
from app.main import app
from tests.test_mail import fake_mail # noqa: F401 (fixture)
H = {"X-Requested-With": "budget-app"}
def user(client, email, role, dept=1):
client.post("/api/users", json={"email": email, "name": email.split("@")[0], "role": role,
"password": "password-123", "department_id": dept})
c = TestClient(app)
c.headers.update(H)
assert c.post("/api/auth/login", json={"email": email, "password": "password-123"}).status_code == 200
return c
def approve(client, year_id):
assert client.post(f"/api/years/{year_id}/status", json={"action": "submit"}).status_code == 200
while client.get(f"/api/years/{year_id}").json()["status"] == "in_review":
assert client.post(f"/api/years/{year_id}/status", json={"action": "approve"}).status_code == 200
def test_reopen_request_needs_reason_and_approver(client, imported):
viewer = user(client, "view@t.local", "viewer", dept=None)
approver = user(client, "ap@t.local", "approver")
approve(client, imported)
y = viewer.get(f"/api/years/{imported}").json()
assert y["actions"]["request_reopen"] and not y["actions"]["reopen"]
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": ""}).status_code == 400
r = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Contrato novo de telecomunicações"})
assert r.status_code == 200 and r.json()["reopened"] is False
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "again please"}).status_code == 409
y = approver.get(f"/api/years/{imported}").json()
assert y["status"] == "approved" and y["reopen_request"]["requested_by"] == "view"
assert y["actions"]["decide_reopen"] and not viewer.get(f"/api/years/{imported}").json()["actions"]["decide_reopen"]
rid = y["reopen_request"]["id"]
assert viewer.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).status_code == 403
assert approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False}).status_code == 400 # say why
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Já aprovado pela direção"})
assert d.json()["request"]["status"] == "declined" and d.json()["year"]["status"] == "approved"
# a second request, accepted this time
rid = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Falta a linha de licenças"}).json()["request"]["id"]
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).json()
assert d["request"]["status"] == "accepted" and d["year"]["status"] == "draft"
hist = client.get(f"/api/years/{imported}").json()["reopen_requests"]
assert [h["status"] for h in hist] == ["accepted", "declined"]
acts = [r["action"] for r in client.get("/api/activity?entity=year").json()["rows"]]
assert {"reopen_requested", "reopen_declined", "reopen"} <= set(acts)
def test_approver_reopens_directly_with_reason(client, imported):
approver = user(client, "ap@t.local", "approver")
approve(client, imported)
r = approver.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Correção de valores"}).json()
assert r["reopened"] is True and r["year"]["status"] == "draft"
def test_download_and_reupload_archives_old_version(client, imported):
# downloads keep the name of the file the budget was loaded from
assert 'filename="b.xlsx"' in client.get(f"/api/years/{imported}/workbook.xlsx").headers["content-disposition"]
fresh = client.post("/api/years", json={"year": 2031}).json()["id"]
assert 'filename="Orcamento_IT_2031.xlsx"' in client.get(f"/api/years/{fresh}/workbook.xlsx?lang=pt").headers["content-disposition"]
client.delete(f"/api/years/{fresh}")
r = client.get(f"/api/years/{imported}/workbook.xlsx?lang=pt")
files = {"file": ("Orcamento_IT_2027.xlsx", r.content)}
check = client.post("/api/years/import/check", files=files, data={"department_id": "1"}).json()
assert check["year"] == 2027 and check["existing"]["id"] == imported and check["existing"]["can_replace"]
assert check["existing"]["archived_title"].startswith("Budget 2027 (")
# without confirming, nothing is replaced
assert client.post("/api/years/import", files=files, data={"department_id": "1"}).status_code == 409
res = client.post("/api/years/import", files=files, data={"department_id": "1", "replace": "true"}).json()
new_id = res["year"]["id"]
assert new_id != imported and res["year"]["title"] == "Budget 2027" and res["year"]["source_file"] == "Orcamento_IT_2027.xlsx"
old = client.get(f"/api/years/{imported}").json()
assert old["archived"] and old["title"].startswith("Budget 2027 (") and old["title"] != "Budget 2027"
assert not any(v for k, v in old["actions"].items() if k != "delete")
line = old["categories"][0]["lines"][0]
assert client.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code in (403, 409)
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code in (403, 409)
assert [y["id"] for y in client.get("/api/years").json()] == [new_id]
assert [y["id"] for y in client.get("/api/years?archived=true").json()] == [imported]
assert client.get(f"/api/years/{imported}/reports/budget.pdf").status_code == 200 # still viewable
assert abs(old["costs"]["adjusted"] - client.get(f"/api/years/{new_id}").json()["costs"]["adjusted"]) < 0.01
# the same file again: the downloaded name is kept, and the previous upload gets the date in its file name
again = client.get(f"/api/years/{new_id}/workbook.xlsx").content
check = client.post("/api/years/import/check", files={"file": ("Orcamento_IT_2027.xlsx", again)},
data={"department_id": "1"}).json()
assert check["existing"]["same_file"] and check["existing"]["archived_file"].startswith("Orcamento_IT_2027_")
client.post("/api/years/import", files={"file": ("Orcamento_IT_2027.xlsx", again)},
data={"department_id": "1", "replace": "true"})
names = [y["source_file"] for y in client.get("/api/years?archived=true").json()]
assert any(n.startswith("Orcamento_IT_2027_2") and n.endswith(".xlsx") for n in names)
def test_approved_budget_cannot_be_replaced(client, imported):
data = client.get(f"/api/years/{imported}/workbook.xlsx").content
approve(client, imported)
check = client.post("/api/years/import/check", files={"file": ("b.xlsx", data)}, data={"department_id": "1"}).json()
assert check["existing"]["can_replace"] is False
r = client.post("/api/years/import", files={"file": ("b.xlsx", data)}, data={"department_id": "1", "replace": "true"})
assert r.status_code == 409 and r.json()["detail"] == "year_not_draft"
def test_approver_reopening_answers_pending_request(client, imported):
viewer = user(client, "view@t.local", "viewer", dept=None)
approve(client, imported)
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Preciso de mudar a linha X"})
r = client.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Concordo, reabrir"}).json()
assert r["reopened"] and r["request"]["status"] == "accepted" and r["request"]["requested_by"] == "view"
assert len(client.get(f"/api/years/{imported}").json()["reopen_requests"]) == 1
def test_reopen_request_emails_the_approvers(client, imported, fake_mail):
from tests.test_mail import FakeSMTP, configure
configure(client)
user(client, "ap@t.local", "approver")
viewer = user(client, "view@t.local", "viewer", dept=None)
approve(client, imported)
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Licenças em falta"})
_, rcpts, msg = FakeSMTP.sent[-1]
assert set(rcpts) == {"admin@test.local", "ap@t.local"} and "Licenças em falta" in msg.get_content()
rid = client.get(f"/api/years/{imported}").json()["reopen_request"]["id"]
client.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Fica para o próximo ano"})
_, rcpts, msg = FakeSMTP.sent[-1]
assert rcpts == ["view@t.local"] and "Fica para o próximo ano" in msg.get_content()
+42
View File
@@ -0,0 +1,42 @@
from tests.test_mail import configure, fake_mail # noqa: F401 (fixture)
from tests.test_v5 import approve, user
def test_upper_management_views_downloads_and_emails_every_department(client, imported, fake_mail):
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
other = client.post("/api/years", json={"department_id": fb["id"], "year": 2027}).json()["id"]
boss = user(client, "boss@t.local", "management", dept=None)
assert boss.get("/api/auth/me").json()["role"] == "management"
years = boss.get("/api/years").json()
assert {y["department_id"] for y in years} == {1, fb["id"]}
assert [y["id"] for y in boss.get(f"/api/years?department_id={fb['id']}").json()] == [other]
for year_id in (imported, other):
y = boss.get(f"/api/years/{year_id}").json()
assert not any(y["actions"].values()), y["actions"]
assert boss.get(f"/api/years/{year_id}/workbook.xlsx").status_code == 200
assert boss.get(f"/api/years/{year_id}/reports/cost-report.pdf").status_code == 200
configure(client)
r = boss.post(f"/api/years/{other}/email", json={"to": ["cfo@example.com"], "reports": [{"kind": "cost-report", "fmt": "pdf"}]})
assert r.status_code == 200, r.text
def test_upper_management_changes_nothing(client, imported):
boss = user(client, "boss@t.local", "management", dept=1) # even with a department
line = client.get(f"/api/years/{imported}").json()["categories"][0]["lines"][0]
assert boss.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 403
assert boss.post("/api/years", json={"year": 2032}).status_code == 403
assert boss.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 403
assert boss.delete(f"/api/years/{imported}").status_code == 403
assert boss.post("/api/categories", json={"department_id": 1, "name_en": "X"}).status_code == 403
assert boss.get("/api/users").status_code == 403 and boss.get("/api/activity").status_code == 403
# never an approver, even when named in a workflow step
me = boss.get("/api/auth/me").json()["id"]
client.put("/api/departments/1/workflow", json={"steps": [{"name": "Board", "approver_mode": "users", "approver_ids": [me]}]})
y = boss.get(f"/api/years/{imported}").json()
assert not y["actions"]["approve"]
# and does not ask for reopening: that is for the people who work on budgets
client.put("/api/departments/1/workflow", json={"steps": []})
approve(client, imported)
y = boss.get(f"/api/years/{imported}").json()
assert y["status"] == "approved" and not y["actions"]["request_reopen"]
assert boss.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Quero rever"}).status_code == 403
+105
View File
@@ -0,0 +1,105 @@
import time
from io import BytesIO
from fastapi.testclient import TestClient
from PIL import Image
from app import mfa
from app.main import app
from tests.test_v5 import H, user
def login(email, password, code=None):
c = TestClient(app)
c.headers.update(H)
body = {"email": email, "password": password}
if code is not None:
body["code"] = code
return c, c.post("/api/auth/login", json=body)
def now_code(secret, offset=0):
return mfa.code_at(secret, int(time.time() // mfa.STEP) + offset)
def test_rfc6238_vectors():
import base64
s = base64.b32encode(b"12345678901234567890").decode().rstrip("=")
assert mfa.code_at(s, 59 // 30) == "287082" and mfa.code_at(s, 1111111109 // 30) == "081804"
def test_mfa_set_up_sign_in_recovery_and_reset(client):
ana = user(client, "ana@t.local", "editor")
setup = ana.post("/api/auth/mfa/setup").json()
assert setup["uri"].startswith("otpauth://totp/") and "<svg" in setup["qr_svg"]
secret = setup["secret"]
# not on until a code is confirmed
assert ana.get("/api/auth/me").json()["mfa_enabled"] is False
assert ana.post("/api/auth/mfa/enable", json={"code": "000000" if now_code(secret) != "000000" else "111111"}).status_code == 400
r = ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)}).json()
codes = r["recovery_codes"]
assert r["user"]["mfa_enabled"] and len(codes) == 8 and r["user"]["mfa_recovery_left"] == 8
listed = {u["email"]: u["mfa_enabled"] for u in client.get("/api/users").json()}
assert listed == {"admin@test.local": False, "ana@t.local": True}
# sign-in now asks for the code; no session without it
c, res = login("ana@t.local", "password-123")
assert res.status_code == 200 and res.json() == {"mfa_required": True}
assert c.get("/api/auth/me").status_code == 401
_, res = login("ana@t.local", "password-123", "123456" if now_code(secret, 1) != "123456" else "654321")
assert res.status_code == 401 and res.json()["detail"] == "invalid_mfa_code"
# the code from the enable step was used already; the next one works once
c, res = login("ana@t.local", "password-123", now_code(secret, 1))
assert res.status_code == 200 and res.json()["email"] == "ana@t.local" and c.get("/api/auth/me").status_code == 200
_, res = login("ana@t.local", "password-123", now_code(secret, 1))
assert res.status_code == 401 # replay refused
# a recovery code works once
c, res = login("ana@t.local", "password-123", codes[0].upper())
assert res.status_code == 200 and res.json()["mfa_recovery_left"] == 7
assert login("ana@t.local", "password-123", codes[0])[1].status_code == 401
# an administrator resets it (lost phone)
me = c.get("/api/auth/me").json()["id"]
assert ana.post(f"/api/users/{me}/mfa/reset").status_code == 403
assert client.post(f"/api/users/{me}/mfa/reset").json()["mfa_enabled"] is False
assert login("ana@t.local", "password-123")[1].json()["email"] == "ana@t.local"
acts = {r["action"] for r in client.get("/api/activity?entity=user").json()["rows"]}
assert {"mfa_enabled", "mfa_failed", "mfa_recovery_used", "mfa_reset"} <= acts
def test_mfa_locks_after_wrong_codes_and_turns_off_with_password_and_code(client):
ana = user(client, "ana@t.local", "viewer")
secret = ana.post("/api/auth/mfa/setup").json()["secret"]
ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)})
for _ in range(mfa.MAX_FAILED - 1):
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 401
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 429
assert login("ana@t.local", "password-123", now_code(secret, 1))[1].status_code == 429 # locked for a while
assert ana.post("/api/auth/mfa/disable", json={"password": "wrong", "code": ""}).status_code == 400
from app.db import SessionLocal
from app.models import User
with SessionLocal() as db:
u = db.query(User).filter_by(email="ana@t.local").one()
u.mfa_locked_until = None
db.commit()
assert ana.post("/api/auth/mfa/disable", json={"password": "password-123", "code": now_code(secret, 1)}).json()["mfa_enabled"] is False
def test_avatar_upload_resize_and_remove(client):
ana = user(client, "ana@t.local", "viewer")
buf = BytesIO()
Image.new("RGB", (900, 600), (200, 30, 30)).save(buf, "PNG")
assert ana.put("/api/auth/me/avatar", files={"file": ("x.txt", b"not a picture")}).status_code == 400
me = ana.put("/api/auth/me/avatar", files={"file": ("me.png", buf.getvalue(), "image/png")}).json()
assert me["avatar_url"].startswith(f"/api/users/{me['id']}/avatar?v=")
pic = client.get(me["avatar_url"]) # other users see it in lists
assert pic.status_code == 200 and pic.headers["content-type"] == "image/jpeg"
assert Image.open(BytesIO(pic.content)).size == (256, 256)
assert ana.delete("/api/auth/me/avatar").json()["avatar_url"] is None
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
def test_theme_is_saved(client):
assert client.get("/api/auth/me").json()["theme"] == "system"
assert client.put("/api/auth/me", json={"theme": "dark"}).json()["theme"] == "dark"
assert client.put("/api/auth/me", json={"theme": "pink"}).json()["theme"] == "dark"