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"]