some testing with python

This commit is contained in:
samantha42
2026-03-20 21:53:55 +01:00
parent 1d06e51db3
commit 7e7c3f6bf4
14 changed files with 3152 additions and 2 deletions

709
testing/tests/test_fpa.py Normal file
View File

@@ -0,0 +1,709 @@
"""
FP&A Test Suite — stdlib only, no pytest
Run: python tests/test_fpa.py
python tests/test_fpa.py --api # include live API tests
python tests/test_fpa.py --url http://localhost:9000
"""
import csv
import json
import os
import sys
import traceback
import urllib.request
import urllib.error
from dataclasses import dataclass, field
from typing import Callable, List, Optional
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "csv")
API_BASE = os.getenv("FPA_API_URL", "http://localhost:8080")
# ── Tiny test runner ──────────────────────────────────────────────────────────
@dataclass
class Result:
name: str
passed: bool
skipped: bool = False
message: str = ""
class Suite:
def __init__(self, name: str):
self.name = name
self.results: List[Result] = []
def run(self, label: str, fn: Callable):
try:
fn()
self.results.append(Result(label, passed=True))
except SkipTest as e:
self.results.append(Result(label, passed=False, skipped=True, message=str(e)))
except AssertionError as e:
self.results.append(Result(label, passed=False, message=str(e)))
except Exception as e:
self.results.append(Result(label, passed=False,
message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}"))
class SkipTest(Exception):
pass
def skip(reason: str):
raise SkipTest(reason)
# ── CSV helper ────────────────────────────────────────────────────────────────
def read_csv(filename: str) -> List[dict]:
path = os.path.join(DATA_DIR, filename)
if not os.path.exists(path):
skip(f"{filename} not found — run generators/generate_data.py first")
with open(path, newline="") as f:
return list(csv.DictReader(f))
# ── HTTP helpers (stdlib only) ────────────────────────────────────────────────
def http_get(url: str, timeout: int = 5):
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
body = json.loads(resp.read().decode())
return resp.status, body
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode())
except Exception:
body = {}
return e.code, body
except Exception:
return 0, {}
def http_post(url: str, payload: dict, timeout: int = 10):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
body = json.loads(raw.decode()) if raw else {}
return resp.status, body
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode())
except Exception:
body = {}
return e.code, body
except Exception:
return 0, {}
def http_put(url: str, payload: dict, timeout: int = 10):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"},
method="PUT")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
body = json.loads(raw.decode()) if raw else {}
return resp.status, body
except urllib.error.HTTPError as e:
return e.code, {}
except Exception:
return 0, {}
def api_available() -> bool:
status, _ = http_get(f"{API_BASE}/api/v1/health", timeout=2)
return status == 200
# ── Assertion helpers ─────────────────────────────────────────────────────────
def assert_true(condition, message: str = ""):
if not condition:
raise AssertionError(message or "Expected True, got False")
def assert_eq(actual, expected, message: str = ""):
if actual != expected:
raise AssertionError(message or f"Expected {expected!r}, got {actual!r}")
# ── Reference data ───────────────────────────────────────────────────────────
#
# Departments and GL accounts are CREATED via the API before any budget/actual.
# seed_reference_data() POSTs them and stores returned auto-increment IDs into
# DEPARTMENT_IDS and GL_ACCOUNTS at runtime — nothing is hardcoded.
# Uses ON CONFLICT upsert on the Go side so re-runs are safe.
BUDGET_VERSION = "v1" # matches BudgetVersion enum in your Go service
# Runtime ID maps — populated by seed_reference_data()
DEPARTMENT_IDS: dict = {}
GL_ACCOUNTS: dict = {} # category name → {"id": int, "code": str}
_DEPARTMENT_DEFS = [
# code, name, cost_center — matches departments table exactly
{"code": "REV", "name": "Revenue", "cost_center": "CC-100", "active": True},
{"code": "ENG", "name": "Engineering", "cost_center": "CC-200", "active": True},
{"code": "SAL", "name": "Sales", "cost_center": "CC-300", "active": True},
{"code": "MKT", "name": "Marketing", "cost_center": "CC-400", "active": True},
{"code": "OPS", "name": "Operations", "cost_center": "CC-500", "active": True},
{"code": "FIN", "name": "Finance", "cost_center": "CC-600", "active": True},
]
_GL_ACCOUNT_DEFS = [
# code, description, type (revenue|cogs|opex|capex|headcount), favour_high
# favour_high=True means over-budget is good (i.e. revenue beating target)
# Revenue
{"code": "4000", "description": "SaaS Subscription Revenue", "type": "revenue", "favour_high": True},
{"code": "4100", "description": "Professional Services Revenue","type": "revenue", "favour_high": True},
# COGS
{"code": "5000", "description": "Cost of Goods — Product", "type": "cogs", "favour_high": False},
{"code": "5100", "description": "Cost of Goods — Service", "type": "cogs", "favour_high": False},
# Opex
{"code": "6000", "description": "Salaries and Wages", "type": "opex", "favour_high": False},
{"code": "6100", "description": "Software and SaaS Tools", "type": "opex", "favour_high": False},
{"code": "6200", "description": "Travel and Expenses", "type": "opex", "favour_high": False},
{"code": "6300", "description": "Marketing and Paid Media", "type": "opex", "favour_high": False},
{"code": "6400", "description": "Cloud Infrastructure", "type": "opex", "favour_high": False},
{"code": "6500", "description": "Contractors and Freelancers","type": "opex", "favour_high": False},
{"code": "6600", "description": "Office and Facilities", "type": "opex", "favour_high": False},
# Capex
{"code": "7000", "description": "Capital Expenditure", "type": "capex", "favour_high": False},
# Headcount
{"code": "9200", "description": "Headcount Cost", "type": "headcount", "favour_high": False},
]
# Maps CSV category names → GL codes so the seeder can find the right account
_CSV_CATEGORY_TO_GL_CODE = {
"Product": "4000",
"Service": "4100",
"Salaries": "6000",
"Software & Tools": "6100",
"Travel": "6200",
"Marketing Spend": "6300",
"Cloud Infrastructure": "6400",
"Contractors": "6500",
"Office & Facilities": "6600",
"P&L Summary": "4000", # roll up to revenue GL for summary lines
"Cash Flow": "7000",
"Headcount": "9200",
}
def seed_reference_data() -> Optional[str]:
"""
POST all departments and GL accounts to the API and store returned IDs.
Returns an error string on first failure, or None on success.
Safe to call multiple times — Go side uses ON CONFLICT(code) upsert.
Populates:
DEPARTMENT_IDS — {"Engineering": 3, ...} keyed by department name
GL_ACCOUNTS — {"4000": {"id": 1, "code": "4000"}, ...} keyed by GL code
"""
for defn in _DEPARTMENT_DEFS:
status, body = http_post(f"{API_BASE}/api/v1/departments", defn)
if status not in (200, 201):
return f"POST /departments '{defn['name']}' failed ({status}): {body}"
DEPARTMENT_IDS[defn["name"]] = body["id"]
for defn in _GL_ACCOUNT_DEFS:
status, body = http_post(f"{API_BASE}/api/v1/gl-accounts", defn)
if status not in (200, 201):
return f"POST /gl-accounts '{defn['code']}' failed ({status}): {body}"
GL_ACCOUNTS[defn["code"]] = {"id": body["id"], "code": defn["code"]}
return None # success
def gl(category: str) -> dict:
"""
Resolve a CSV category name to a seeded GL account dict {"id": int, "code": str}.
Looks up via _CSV_CATEGORY_TO_GL_CODE then into the runtime GL_ACCOUNTS map.
"""
code = _CSV_CATEGORY_TO_GL_CODE.get(category)
if code is None:
raise AssertionError(
f"No GL code mapping for CSV category '{category}'. "
f"Add it to _CSV_CATEGORY_TO_GL_CODE."
)
acct = GL_ACCOUNTS.get(code)
if acct is None:
raise AssertionError(
f"GL code '{code}' (for '{category}') not in runtime map — "
f"was seed_reference_data() called? Known codes: {list(GL_ACCOUNTS.keys())}"
)
return acct
def period_to_fiscal(period: str) -> tuple:
"""'2023-04' → (2023, 4)"""
year, month = period.split("-")
return int(year), int(month)
# ── suite_revenue — full API round-trip ───────────────────────────────────────
#
# Flow:
# 1. Load CSV rows (local data validation)
# 2. POST each row as a CreateBudgetRequest → /api/v1/budgets
# 3. POST actuals for the same rows → /api/v1/actuals/ingest
# 4. GET /api/v1/variance → verify amounts round-trip correctly
def suite_revenue() -> Suite:
"""
Maps to Go structs:
CreateBudgetRequest:
fiscal_year, fiscal_period, version, department_id,
gl_account_id, amount, currency, notes, created_by
Actual (ingest):
fiscal_year, fiscal_period, department_id,
gl_account_id, gl_code, amount, currency, source
"""
s = Suite("Revenue — CSV + API round-trip")
if not api_available():
s.run("API reachable", lambda: skip(f"Go API not running at {API_BASE}"))
return s
rows = []
# ── Step 1: load and validate CSV data ────────────────────────────────────
def load():
rows.extend(read_csv("revenue_budget_vs_actuals.csv"))
s.run("CSV loads without error", load)
s.run("CSV has 48 rows (24 months × 2 revenue types)", lambda:
assert_eq(len(rows), 48))
s.run("CSV revenue types are Product and Service only", lambda:
assert_eq({r["revenue_type"] for r in rows}, {"Product", "Service"}))
def check_csv_variance():
for r in rows:
diff = float(r["actual_amount"]) - float(r["budget_amount"])
assert_true(abs(diff - float(r["variance"])) < 0.01,
f"CSV variance mismatch in {r['period']}")
s.run("CSV variance = actual budget", check_csv_variance)
# ── Step 2: seed departments + GL accounts ────────────────────────────────
# Must succeed before any budget POST — these rows are the FK parents.
def seed_refs():
err = seed_reference_data()
assert_true(err is None, err or "seed_reference_data failed")
s.run("POST /api/v1/departments + /api/v1/gl-accounts (seed FK parents)", seed_refs)
# ── Step 3: POST budgets ──────────────────────────────────────────────────
# One CreateBudgetRequest per CSV row.
# We only post the first period (2023-01) for both types to keep the
# test focused; the loader handles the full dataset.
budget_ids: dict[str, int] = {} # key: "revenue_type:period" → returned id
def post_product_budget():
row = next(r for r in rows
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
fy, fp = period_to_fiscal(row["period"])
payload = {
"fiscal_year": fy,
"fiscal_period": fp,
"version": BUDGET_VERSION,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": gl("Product")["id"],
"amount": float(row["budget_amount"]),
"currency": "USD",
"notes": "Product revenue budget — csv import",
"created_by": "test_suite",
}
status, body = http_post(f"{API_BASE}/api/v1/budgets", payload)
assert_true(status in (200, 201),
f"POST /budgets failed ({status}): {body}")
budget_id = body.get("id")
assert_true(budget_id is not None, "Response missing 'id' field")
budget_ids["Product:2023-01"] = budget_id
s.run("POST /api/v1/budgets — Product revenue 2023-01", post_product_budget)
def post_service_budget():
row = next(r for r in rows
if r["revenue_type"] == "Service" and r["period"] == "2023-01")
fy, fp = period_to_fiscal(row["period"])
payload = {
"fiscal_year": fy,
"fiscal_period": fp,
"version": BUDGET_VERSION,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": gl("Service")["id"],
"amount": float(row["budget_amount"]),
"currency": "USD",
"notes": "Service revenue budget — csv import",
"created_by": "test_suite",
}
status, body = http_post(f"{API_BASE}/api/v1/budgets", payload)
assert_true(status in (200, 201),
f"POST /budgets failed ({status}): {body}")
budget_ids["Service:2023-01"] = body.get("id")
s.run("POST /api/v1/budgets — Service revenue 2023-01", post_service_budget)
# ── Step 4: POST actuals ──────────────────────────────────────────────────
# Ingest actual amounts for the same period so variance can be computed.
ingested_actuals: list[dict] = []
def post_actuals():
period_rows = [r for r in rows if r["period"] == "2023-01"]
records = []
for r in period_rows:
fy, fp = period_to_fiscal(r["period"])
account = gl(r["revenue_type"])
records.append({
"fiscal_year": fy,
"fiscal_period": fp,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": account["id"],
"gl_code": account["code"],
"amount": float(r["actual_amount"]),
"currency": "USD",
"source": "test_suite_csv",
})
status, body = http_post(f"{API_BASE}/api/v1/actuals/ingest",
{"records": records})
assert_true(status in (200, 201),
f"POST /actuals/ingest failed ({status}): {body}")
# Store returned actuals for later assertions
returned = body.get("actuals") or body.get("records") or []
ingested_actuals.extend(returned)
s.run("POST /api/v1/actuals/ingest — Product + Service 2023-01", post_actuals)
# ── Step 5: GET variance and verify numbers ───────────────────────────────
variance_data: list[dict] = []
def fetch_variance():
url = f"{API_BASE}/api/v1/variance?fiscal_year=2023&fiscal_period=1"
status, body = http_get(url)
assert_true(status == 200, f"GET /variance failed ({status}): {body}")
# API may return a list directly or wrapped in a key
items = body if isinstance(body, list) else body.get("variance") or body.get("data") or []
assert_true(len(items) > 0, "Variance response is empty — data may not have landed")
variance_data.extend(items)
s.run("GET /api/v1/variance returns entries for 2023-01", fetch_variance)
def verify_product_variance():
csv_row = next(r for r in rows
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
expected_budget = float(csv_row["budget_amount"])
expected_actual = float(csv_row["actual_amount"])
expected_variance = float(csv_row["variance"])
# Find the matching variance entry by GL account id
entry = next(
(v for v in variance_data
if v.get("gl_account_id") == gl("Product")["id"]
or v.get("gl_code") == gl("Product")["code"]),
None
)
assert_true(entry is not None,
f"No variance entry found for Product (GL {gl('Product')['id']})")
api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0)
api_actual = float(entry.get("actual_amount") or entry.get("actual") or 0)
api_variance = float(entry.get("variance") or entry.get("variance_amount") or 0)
assert_true(abs(api_budget - expected_budget) < 1.0,
f"Budget mismatch: API={api_budget} CSV={expected_budget}")
assert_true(abs(api_actual - expected_actual) < 1.0,
f"Actual mismatch: API={api_actual} CSV={expected_actual}")
assert_true(abs(api_variance - expected_variance) < 1.0,
f"Variance mismatch: API={api_variance} CSV={expected_variance}")
s.run("Variance amounts match CSV for Product 2023-01", verify_product_variance)
def verify_service_variance():
csv_row = next(r for r in rows
if r["revenue_type"] == "Service" and r["period"] == "2023-01")
expected_budget = float(csv_row["budget_amount"])
expected_actual = float(csv_row["actual_amount"])
expected_variance = float(csv_row["variance"])
entry = next(
(v for v in variance_data
if v.get("gl_account_id") == gl("Service")["id"]
or v.get("gl_code") == gl("Service")["code"]),
None
)
assert_true(entry is not None,
f"No variance entry found for Service (GL {gl('Service')['id']})")
api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0)
api_actual = float(entry.get("actual_amount") or entry.get("actual") or 0)
api_variance = float(entry.get("variance") or entry.get("variance_amount") or 0)
assert_true(abs(api_budget - expected_budget) < 1.0,
f"Budget mismatch: API={api_budget} CSV={expected_budget}")
assert_true(abs(api_actual - expected_actual) < 1.0,
f"Actual mismatch: API={api_actual} CSV={expected_actual}")
assert_true(abs(api_variance - expected_variance) < 1.0,
f"Variance mismatch: API={api_variance} CSV={expected_variance}")
s.run("Variance amounts match CSV for Service 2023-01", verify_service_variance)
# ── Step 6: update a budget and verify variance shifts ────────────────────
def update_and_reverify():
budget_id = budget_ids.get("Product:2023-01")
if not budget_id:
skip("Product budget id not captured — skipping update test")
csv_row = next(r for r in rows
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
revised_amount = float(csv_row["budget_amount"]) * 1.10 # +10% revision
fy, fp = period_to_fiscal("2023-01")
status, body = http_put(
f"{API_BASE}/api/v1/budgets/{budget_id}",
{
"fiscal_year": fy,
"fiscal_period": fp,
"version": BUDGET_VERSION,
"department_id": DEPARTMENT_IDS["Revenue"],
"gl_account_id": gl("Product")["id"],
"amount": revised_amount,
"currency": "USD",
"notes": "revised +10% mid-cycle",
"created_by": "test_suite",
}
)
assert_true(status in (200, 204),
f"PUT /budgets/{budget_id} failed ({status}): {body}")
# Re-fetch variance — the variance amount should now reflect the new budget
url = f"{API_BASE}/api/v1/variance?fiscal_year=2023&fiscal_period=1"
status2, body2 = http_get(url)
assert_true(status2 == 200, f"Re-fetch variance failed ({status2})")
items = body2 if isinstance(body2, list) else body2.get("variance") or body2.get("data") or []
entry = next(
(v for v in items if v.get("gl_account_id") == gl("Product")["id"]
or v.get("gl_code") == gl("Product")["code"]),
None
)
assert_true(entry is not None, "Product variance entry missing after budget update")
api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0)
assert_true(abs(api_budget - revised_amount) < 1.0,
f"Updated budget not reflected: API={api_budget} expected={revised_amount:.2f}")
s.run("PUT /api/v1/budgets/{id} — variance reflects revised budget", update_and_reverify)
return s
# ── Remaining CSV-only suites (unchanged) ─────────────────────────────────────
def suite_opex() -> Suite:
s = Suite("Opex CSV")
rows = []
def load():
rows.extend(read_csv("opex_budget_vs_actuals.csv"))
s.run("loads without error", load)
s.run("has rows", lambda: assert_true(len(rows) > 0))
s.run("all departments present", lambda:
assert_true(
{"Engineering", "Sales", "Marketing", "Operations"}.issubset(
{r["department"] for r in rows}
)))
def check_no_zero():
for r in rows:
assert_true(float(r["budget_amount"]) > 0,
f"Zero budget: {r['department']} / {r['category']}")
s.run("no zero budget amounts", check_no_zero)
def check_sign():
for r in rows:
actual = float(r["actual_amount"])
budget = float(r["budget_amount"])
variance = float(r["variance"])
exp = 1 if actual > budget else (-1 if actual < budget else 0)
got = 1 if variance > 0 else (-1 if variance < 0 else 0)
assert_eq(got, exp, f"Variance sign wrong in {r['period']} {r['category']}")
s.run("variance sign matches actual vs budget", check_sign)
return s
def suite_pl() -> Suite:
s = Suite("P&L CSV")
rows = []
def load():
rows.extend(read_csv("pl_income_statement.csv"))
s.run("loads without error", load)
s.run("exactly 24 rows", lambda: assert_eq(len(rows), 24))
def check_rev_sum():
for r in rows:
total = float(r["product_revenue"]) + float(r["service_revenue"])
assert_true(abs(total - float(r["total_revenue"])) < 0.05,
f"Revenue sum mismatch in {r['period']}")
s.run("total_revenue = product + service", check_rev_sum)
def check_gross():
for r in rows:
gp = float(r["total_revenue"]) - float(r["total_cogs"])
assert_true(abs(gp - float(r["gross_profit"])) < 0.05,
f"Gross profit mismatch in {r['period']}")
s.run("gross_profit = revenue cogs", check_gross)
def check_margin():
for r in rows:
gm = float(r["gross_margin_pct"])
assert_true(30 <= gm <= 90, f"Gross margin {gm}% out of range in {r['period']}")
s.run("gross margin between 30% and 90%", check_margin)
def check_ebitda():
for r in rows:
ebitda = float(r["gross_profit"]) - float(r["total_opex"])
assert_true(abs(ebitda - float(r["ebitda"])) < 0.05,
f"EBITDA mismatch in {r['period']}")
s.run("ebitda = gross_profit opex", check_ebitda)
return s
def suite_cashflow() -> Suite:
s = Suite("Cash Flow CSV")
rows = []
def load():
rows.extend(read_csv("cash_flow.csv"))
s.run("loads without error", load)
s.run("exactly 24 rows", lambda: assert_eq(len(rows), 24))
def check_net():
for r in rows:
total = (float(r["net_operating_cash_flow"]) +
float(r["net_investing_cash_flow"]) +
float(r["net_financing_cash_flow"]))
assert_true(abs(total - float(r["net_change_in_cash"])) < 0.05,
f"Cash flow sum mismatch in {r['period']}")
s.run("net_change = operating + investing + financing", check_net)
def check_pre_series_a():
for r in rows:
if r["period"] <= "2023-05":
assert_true(float(r["closing_cash_balance"]) > 0,
f"Negative cash too early: {r['period']}")
s.run("cash balance positive before Series A (pre 2023-06)", check_pre_series_a)
def check_series_a():
june = next((r for r in rows if r["period"] == "2023-06"), None)
assert_true(june is not None, "2023-06 row missing")
assert_true(float(june["equity_raised"]) > 0, "Series A not recorded in 2023-06")
s.run("Series A visible in 2023-06", check_series_a)
return s
def suite_headcount() -> Suite:
s = Suite("Headcount CSV")
rows = []
def load():
rows.extend(read_csv("headcount_workforce.csv"))
s.run("loads without error", load)
s.run("has rows", lambda: assert_true(len(rows) > 0))
s.run("status only Active or Terminated", lambda:
assert_true({r["status"] for r in rows}.issubset({"Active", "Terminated"})))
def check_fte():
for r in rows:
fte = float(r["headcount_fte"])
assert_true(0 < fte <= 1.0,
f"FTE {fte} out of range for {r['employee_id']}")
s.run("FTE between 0 and 1.0", check_fte)
def check_salary():
for r in rows:
assert_true(float(r["annual_salary_budget"]) > 0)
s.run("all salaries positive", check_salary)
def check_growth():
jan = [r for r in rows if r["period"] == "2023-01" and r["status"] == "Active"]
dec = [r for r in rows if r["period"] == "2024-12" and r["status"] == "Active"]
assert_true(len(dec) >= len(jan),
f"Headcount shrank: {len(jan)} in Jan 2023 → {len(dec)} in Dec 2024")
s.run("headcount grows from 2023-01 to 2024-12", check_growth)
return s
# ── Reporter ──────────────────────────────────────────────────────────────────
PASS = "\033[32m✓\033[0m"
FAIL = "\033[31m✗\033[0m"
SKIP = "\033[33m⊘\033[0m"
BOLD = "\033[1m"
RESET = "\033[0m"
def run_suites(suites: List[Suite]) -> bool:
total = passed = failed = skipped = 0
for suite in suites:
print(f"\n{BOLD}{suite.name}{RESET}")
for r in suite.results:
total += 1
if r.skipped:
skipped += 1
print(f" {SKIP} {r.name}")
print(f" {r.message}")
elif r.passed:
passed += 1
print(f" {PASS} {r.name}")
else:
failed += 1
print(f" {FAIL} {r.name}")
for line in r.message.strip().splitlines():
print(f" {line}")
print(f"\n{'' * 48}")
color = "\033[32m" if failed == 0 else "\033[31m"
print(f"{color}{BOLD}{passed}/{total} passed{RESET}"
+ (f" {SKIP} {skipped} skipped" if skipped else "")
+ (f" {FAIL} {failed} failed" if failed else ""))
print()
return failed == 0
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="FP&A test suite — no dependencies needed")
parser.add_argument("--api", action="store_true",
help="Include revenue round-trip API test (requires Go API running)")
parser.add_argument("--url", default=None, help="Override API base URL")
args = parser.parse_args()
if args.url:
API_BASE = args.url
# suite_revenue always runs — it skips the API steps gracefully if not available
suites = [
suite_revenue(),
suite_opex(),
suite_pl(),
suite_cashflow(),
suite_headcount(),
]
ok = run_suites(suites)
sys.exit(0 if ok else 1)