grantforge-api / backend /tests /test_api_eligibility_strategy_surface.py
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
6.53 kB
"""Structural + unit: API handlers call shipped eligibility/strategy/tax functions."""
from __future__ import annotations
import inspect
import pytest
from fastapi import HTTPException
from endpoints import company as company_ep
from core.eligibility.spine import (
build_eligibility_report,
eligibility_override_is_complete,
)
from core.strategy.recommend import PATH_TAX, recommend_strategy_paths
from core.strategy.tax_paths import (
PARENT_STRATEGY_PATH_ID,
TAX_PATH_IP_BOX,
TAX_PATH_PSI,
TAX_PATH_ULGA_BR,
)
def test_company_router_has_eligibility_and_strategy_routes():
paths = {getattr(r, "path", "") for r in company_ep.router.routes}
# FastAPI may store path without prefix on route
joined = " ".join(sorted(paths))
assert "eligibility" in joined
assert "strategy" in joined or "recommend" in joined
assert "tax" in joined
def test_tax_router_has_paths_route():
"""Plan §8 GET /api/tax/paths + company /tax/paths both exposed."""
company_paths = " ".join(
sorted(getattr(r, "path", "") for r in company_ep.router.routes)
)
assert "tax" in company_paths and "paths" in company_paths
tax_paths = {getattr(r, "path", "") for r in company_ep.tax_router.routes}
joined = " ".join(sorted(tax_paths))
assert "paths" in joined
assert any(
getattr(r, "path", "").endswith("/paths") or getattr(r, "path", "") == "/paths"
for r in company_ep.tax_router.routes
)
def test_get_eligibility_handler_uses_build_report():
src = inspect.getsource(company_ep.get_company_eligibility)
assert "build_eligibility_report" in src
def test_strategy_handler_uses_recommend():
src = inspect.getsource(company_ep.post_strategy_recommend)
assert "recommend_strategy_paths" in src
# POST must load profile spine when eligibility omitted (parity with GET)
assert "company_data_from_profile" in src
assert "get_db" in src or "db" in src
# Incomplete body.eligibility must rebuild (not fail-open)
assert "eligibility_override_is_complete" in src
src2 = inspect.getsource(company_ep.get_strategy_recommend)
assert "recommend_strategy_paths" in src2
def test_handlers_are_callable_with_pure_backend():
# Direct pure path the handlers wrap
rep = build_eligibility_report(
nip="8721990161",
company_data={"msp_status": "mikro", "de_minimis_manual_eur": 100},
)
strat = recommend_strategy_paths(eligibility=rep, goal="B+R", company_data={})
assert rep["nip"]
assert len(strat["paths"]) >= 4
tax = next(p for p in strat["paths"] if p["id"] == PATH_TAX)
assert tax.get("tax_paths") and len(tax["tax_paths"]) == 3
assert tax.get("never_say_submit_to_parp") is True
def test_get_tax_paths_api_surface():
"""GET tax-paths returns PSI / ulga_br / ip_box with PSI PARP guardrail."""
out = company_ep.get_tax_paths(path_id=None, token_data={"sub": "user_tax_api"})
assert out["status"] == "ok"
assert out["parent_strategy_path_id"] == PARENT_STRATEGY_PATH_ID
assert out["checklist_ids"] == [TAX_PATH_PSI, TAX_PATH_ULGA_BR, TAX_PATH_IP_BOX]
assert len(out["paths"]) == 3
ids = [p["id"] for p in out["paths"]]
assert ids == [TAX_PATH_PSI, TAX_PATH_ULGA_BR, TAX_PATH_IP_BOX]
psi = next(p for p in out["paths"] if p["id"] == TAX_PATH_PSI)
assert psi["never_say_submit_to_parp"] is True
assert isinstance(psi["checklist"], list) and len(psi["checklist"]) >= 3
blob = " ".join(psi["checklist"] + psi.get("advisor_notes", [])).lower()
assert "parp" in blob
def test_get_tax_paths_single_id_and_404():
one = company_ep.get_tax_paths(path_id="psi", token_data={"sub": "user_tax_api"})
assert one["status"] == "ok"
assert one["path"]["id"] == TAX_PATH_PSI
assert one["checklist_ids"] == [TAX_PATH_PSI]
with pytest.raises(HTTPException) as ei:
company_ep.get_tax_paths(path_id="not_a_path", token_data={"sub": "user_tax_api"})
assert ei.value.status_code == 404
def test_get_tax_paths_plan_alias_delegates():
"""Plan-canonical /api/tax/paths handler shares response shape."""
a = company_ep.get_tax_paths(path_id=None, token_data={"sub": "u"})
b = company_ep.get_tax_paths_plan_alias(path_id=None, token_data={"sub": "u"})
assert a["checklist_ids"] == b["checklist_ids"]
assert len(a["paths"]) == len(b["paths"]) == 3
def test_get_tax_paths_compact_alias():
"""GET /api/company/tax-paths compact surface (enabled flag + catalog)."""
out = company_ep.get_tax_paths_compact(token_data={"sub": "user_tax_compact"})
assert out["status"] == "ok"
assert out["enabled"] is True
assert len(out["tax_paths"]) == 3
ids = [p["id"] for p in out["tax_paths"]]
assert ids == [TAX_PATH_PSI, TAX_PATH_ULGA_BR, TAX_PATH_IP_BOX]
psi = next(p for p in out["tax_paths"] if p["id"] == TAX_PATH_PSI)
assert psi["never_say_submit_to_parp"] is True
def test_post_strategy_incomplete_eligibility_rebuilds_and_includes_tax():
"""Thin body.eligibility rebuilds spine; strategy still embeds tax checklists."""
class _EmptyDB:
def query(self, *a, **k):
class _Q:
def filter(self, *a, **k):
return self
def first(self):
return None
return _Q()
body = company_ep.StrategyRecommendBody(
nip="8721990161",
goal="B+R",
eligibility={"msp": {"status": "mikro"}}, # incomplete — no de_minimis
company_data={"msp_status": "mikro", "de_minimis_manual_eur": 100},
)
assert eligibility_override_is_complete(body.eligibility) is False
out = company_ep.post_strategy_recommend(
body=body, token_data={"sub": "user_tax_strat"}, db=_EmptyDB()
)
elig = out["eligibility"]
assert elig.get("de_minimis", {}).get("used_eur") == 100.0
assert elig.get("msp", {}).get("status")
tax = next(p for p in out["strategy"]["paths"] if p["id"] == PATH_TAX)
assert tax.get("tax_paths") and len(tax["tax_paths"]) == 3
assert tax.get("never_say_submit_to_parp") is True
def test_post_strategy_incomplete_eligibility_rebuilds_in_handler_source():
"""Guard residual: thin eligibility must not skip spine rebuild."""
assert eligibility_override_is_complete({"msp": {"status": "mikro"}}) is False
src = inspect.getsource(company_ep.post_strategy_recommend)
assert "if not eligibility_override_is_complete" in src
assert "build_eligibility_report" in src