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()