better testing idk
This commit is contained in:
@@ -70,11 +70,20 @@ const budgetSelectCols = `
|
|||||||
amount, currency, notes, created_by, created_at, updated_at`
|
amount, currency, notes, created_by, created_at, updated_at`
|
||||||
|
|
||||||
func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest) (*model.Budget, error) {
|
func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest) (*model.Budget, error) {
|
||||||
|
// ON CONFLICT upsert: if the unique key (year, period, version, dept, gl) already
|
||||||
|
// exists, update the mutable fields instead of failing.
|
||||||
|
// Makes the endpoint idempotent — safe for repeated test runs and re-imports.
|
||||||
res, err := r.db.ExecContext(ctx, `
|
res, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO budgets
|
INSERT INTO budgets
|
||||||
(fiscal_year, fiscal_period, version, department_id, gl_account_id,
|
(fiscal_year, fiscal_period, version, department_id, gl_account_id,
|
||||||
amount, currency, notes, created_by)
|
amount, currency, notes, created_by)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(fiscal_year, fiscal_period, version, department_id, gl_account_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
amount = excluded.amount,
|
||||||
|
currency = excluded.currency,
|
||||||
|
notes = excluded.notes,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')`,
|
||||||
req.FiscalYear, req.FiscalPeriod, req.Version,
|
req.FiscalYear, req.FiscalPeriod, req.Version,
|
||||||
req.DepartmentID, req.GLAccountID, req.Amount,
|
req.DepartmentID, req.GLAccountID, req.Amount,
|
||||||
req.Currency, req.Notes, req.CreatedBy,
|
req.Currency, req.Notes, req.CreatedBy,
|
||||||
@@ -83,13 +92,14 @@ func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest)
|
|||||||
return nil, fmt.Errorf("create budget: %w", err)
|
return nil, fmt.Errorf("create budget: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LastInsertId returns the existing row id on a conflict branch in SQLite.
|
||||||
id, err := res.LastInsertId()
|
id, err := res.LastInsertId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("last insert id: %w", err)
|
return nil, fmt.Errorf("last insert id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
row := r.db.QueryRowContext(ctx,
|
row := r.db.QueryRowContext(ctx,
|
||||||
`SELECT`+budgetSelectCols+`FROM budgets WHERE id = ?`, id)
|
`SELECT `+budgetSelectCols+` FROM budgets WHERE id = ?`, id)
|
||||||
b, err := scanBudget(row)
|
b, err := scanBudget(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch created budget: %w", err)
|
return nil, fmt.Errorf("fetch created budget: %w", err)
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
FP&A Test Suite — stdlib only, no pytest
|
FP&A Test Suite
|
||||||
Run: python tests/test_fpa.py
|
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
|
python tests/test_fpa.py --url http://localhost:9000
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv, json, os, sys, traceback
|
||||||
import json
|
from dataclasses import dataclass
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Callable, List, Optional
|
from typing import Callable, List, Optional
|
||||||
|
import requests
|
||||||
|
|
||||||
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "csv")
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "csv")
|
||||||
API_BASE = os.getenv("FPA_API_URL", "http://localhost:8080")
|
API_BASE = os.getenv("FPA_API_URL", "http://localhost:8080")
|
||||||
|
|
||||||
# ── Tiny test runner ──────────────────────────────────────────────────────────
|
# ── Runner ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Result:
|
class Result:
|
||||||
@@ -27,6 +21,12 @@ class Result:
|
|||||||
skipped: bool = False
|
skipped: bool = False
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
class SkipTest(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def skip(reason: str):
|
||||||
|
raise SkipTest(reason)
|
||||||
|
|
||||||
class Suite:
|
class Suite:
|
||||||
def __init__(self, name: str):
|
def __init__(self, name: str):
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -44,13 +44,45 @@ class Suite:
|
|||||||
self.results.append(Result(label, passed=False,
|
self.results.append(Result(label, passed=False,
|
||||||
message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}"))
|
message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}"))
|
||||||
|
|
||||||
class SkipTest(Exception):
|
# ── Assertions ────────────────────────────────────────────────────────────────
|
||||||
pass
|
|
||||||
|
|
||||||
def skip(reason: str):
|
def ok(condition, msg=""):
|
||||||
raise SkipTest(reason)
|
if not condition:
|
||||||
|
raise AssertionError(msg or "Expected True")
|
||||||
|
|
||||||
# ── CSV helper ────────────────────────────────────────────────────────────────
|
def eq(actual, expected, msg=""):
|
||||||
|
if actual != expected:
|
||||||
|
raise AssertionError(msg or f"Expected {expected!r}, got {actual!r}")
|
||||||
|
|
||||||
|
def near(actual, expected, tol=1.0, msg=""):
|
||||||
|
if abs(actual - expected) > tol:
|
||||||
|
raise AssertionError(msg or f"Expected ~{expected:.2f}, got {actual:.2f} (tol={tol})")
|
||||||
|
|
||||||
|
# ── HTTP ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update({"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
def get(path: str, **params):
|
||||||
|
r = session.get(f"{API_BASE}{path}", params=params, timeout=5)
|
||||||
|
return r.status_code, r.json() if r.content else {}
|
||||||
|
|
||||||
|
def post(path: str, body: dict):
|
||||||
|
r = session.post(f"{API_BASE}{path}", json=body, timeout=10)
|
||||||
|
return r.status_code, r.json() if r.content else {}
|
||||||
|
|
||||||
|
def put(path: str, body: dict):
|
||||||
|
r = session.put(f"{API_BASE}{path}", json=body, timeout=10)
|
||||||
|
return r.status_code, r.json() if r.content else {}
|
||||||
|
|
||||||
|
def api_up() -> bool:
|
||||||
|
try:
|
||||||
|
status, _ = get("/api/v1/health")
|
||||||
|
return status == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── CSV ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_csv(filename: str) -> List[dict]:
|
def read_csv(filename: str) -> List[dict]:
|
||||||
path = os.path.join(DATA_DIR, filename)
|
path = os.path.join(DATA_DIR, filename)
|
||||||
@@ -59,87 +91,13 @@ def read_csv(filename: str) -> List[dict]:
|
|||||||
with open(path, newline="") as f:
|
with open(path, newline="") as f:
|
||||||
return list(csv.DictReader(f))
|
return list(csv.DictReader(f))
|
||||||
|
|
||||||
# ── HTTP helpers (stdlib only) ────────────────────────────────────────────────
|
# ── Reference data ────────────────────────────────────────────────────────────
|
||||||
|
# Seeded once via the API; IDs stored at runtime.
|
||||||
|
|
||||||
def http_get(url: str, timeout: int = 5):
|
DEPT_IDS: dict[str, int] = {} # name → id
|
||||||
try:
|
GL_IDS: dict[str, int] = {} # code → id
|
||||||
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):
|
DEPARTMENTS = [
|
||||||
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 definitions ────────────────────────────────────────────────────
|
|
||||||
# Matches model.Department: id, code, name, cost_center (no active field)
|
|
||||||
|
|
||||||
_DEPARTMENT_DEFS = [
|
|
||||||
{"code": "REV", "name": "Revenue", "cost_center": "CC-100"},
|
{"code": "REV", "name": "Revenue", "cost_center": "CC-100"},
|
||||||
{"code": "ENG", "name": "Engineering", "cost_center": "CC-200"},
|
{"code": "ENG", "name": "Engineering", "cost_center": "CC-200"},
|
||||||
{"code": "SAL", "name": "Sales", "cost_center": "CC-300"},
|
{"code": "SAL", "name": "Sales", "cost_center": "CC-300"},
|
||||||
@@ -148,19 +106,11 @@ _DEPARTMENT_DEFS = [
|
|||||||
{"code": "FIN", "name": "Finance", "cost_center": "CC-600"},
|
{"code": "FIN", "name": "Finance", "cost_center": "CC-600"},
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── GL account definitions ────────────────────────────────────────────────────
|
GL_ACCOUNTS = [
|
||||||
# Matches model.GLAccount: id, code, description, type (GLAccountType), favour_high
|
|
||||||
# GLAccountType values: "revenue" | "cogs" | "opex" | "capex" | "headcount"
|
|
||||||
# favour_high=True → beating budget is favourable (revenue accounts)
|
|
||||||
|
|
||||||
_GL_ACCOUNT_DEFS = [
|
|
||||||
# Revenue
|
|
||||||
{"code": "4000", "description": "SaaS Subscription Revenue", "type": "revenue", "favour_high": True},
|
{"code": "4000", "description": "SaaS Subscription Revenue", "type": "revenue", "favour_high": True},
|
||||||
{"code": "4100", "description": "Professional Services 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": "5000", "description": "Cost of Goods — Product", "type": "cogs", "favour_high": False},
|
||||||
{"code": "5100", "description": "Cost of Goods — Service", "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": "6000", "description": "Salaries and Wages", "type": "opex", "favour_high": False},
|
||||||
{"code": "6100", "description": "Software and SaaS Tools", "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": "6200", "description": "Travel and Expenses", "type": "opex", "favour_high": False},
|
||||||
@@ -168,16 +118,12 @@ _GL_ACCOUNT_DEFS = [
|
|||||||
{"code": "6400", "description": "Cloud Infrastructure", "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": "6500", "description": "Contractors and Freelancers", "type": "opex", "favour_high": False},
|
||||||
{"code": "6600", "description": "Office and Facilities", "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},
|
{"code": "7000", "description": "Capital Expenditure", "type": "capex", "favour_high": False},
|
||||||
# Headcount
|
|
||||||
{"code": "9200", "description": "Headcount Cost", "type": "headcount", "favour_high": False},
|
{"code": "9200", "description": "Headcount Cost", "type": "headcount", "favour_high": False},
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── CSV category → GL code mapping ───────────────────────────────────────────
|
# CSV category name → GL code
|
||||||
# Translates CSV column values into the gl_code field used by IngestActualsRequest
|
CSV_TO_GL = {
|
||||||
|
|
||||||
_CSV_CATEGORY_TO_GL_CODE = {
|
|
||||||
"Product": "4000",
|
"Product": "4000",
|
||||||
"Service": "4100",
|
"Service": "4100",
|
||||||
"Salaries": "6000",
|
"Salaries": "6000",
|
||||||
@@ -187,530 +133,327 @@ _CSV_CATEGORY_TO_GL_CODE = {
|
|||||||
"Cloud Infrastructure": "6400",
|
"Cloud Infrastructure": "6400",
|
||||||
"Contractors": "6500",
|
"Contractors": "6500",
|
||||||
"Office & Facilities": "6600",
|
"Office & Facilities": "6600",
|
||||||
"P&L Summary": "4000",
|
|
||||||
"Cash Flow": "7000",
|
|
||||||
"Headcount": "9200",
|
"Headcount": "9200",
|
||||||
}
|
}
|
||||||
|
|
||||||
def seed_reference_data() -> Optional[str]:
|
def gl_id(category: str) -> int:
|
||||||
"""
|
code = CSV_TO_GL.get(category)
|
||||||
POST all departments and GL accounts to the API and cache returned IDs.
|
assert code, f"No GL mapping for '{category}'"
|
||||||
|
assert code in GL_IDS, f"GL '{code}' not seeded — call seed() first"
|
||||||
|
return GL_IDS[code]
|
||||||
|
|
||||||
Populates at runtime:
|
def gl_code(category: str) -> str:
|
||||||
DEPARTMENT_IDS — {"Engineering": 3, ...} keyed by dept name
|
code = CSV_TO_GL.get(category)
|
||||||
GL_ACCOUNTS — {"4000": {"id": 1, "code": "4000"}} keyed by GL code
|
assert code, f"No GL mapping for '{category}'"
|
||||||
|
return code
|
||||||
|
|
||||||
Safe to call multiple times — Go side uses ON CONFLICT(code) upsert.
|
def fiscal(period: str):
|
||||||
Returns an error string on first failure, None on success.
|
y, m = period.split("-")
|
||||||
"""
|
return int(y), int(m)
|
||||||
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:
|
def seed():
|
||||||
status, body = http_post(f"{API_BASE}/api/v1/gl-accounts", defn)
|
"""POST departments and GL accounts; cache returned IDs as int. Idempotent."""
|
||||||
if status not in (200, 201):
|
for d in DEPARTMENTS:
|
||||||
return f"POST /gl-accounts code='{defn['code']}' failed ({status}): {body}"
|
status, body = post("/api/v1/departments", d)
|
||||||
GL_ACCOUNTS[defn["code"]] = {"id": body["id"], "code": defn["code"]}
|
ok(status in (200, 201), f"POST /departments '{d['name']}' → {status}: {body}")
|
||||||
|
ok("id" in body, f"departments response missing 'id': {body}")
|
||||||
|
DEPT_IDS[d["name"]] = int(body["id"]) # explicit int — guards against "id": "3"
|
||||||
|
|
||||||
return None
|
for g in GL_ACCOUNTS:
|
||||||
|
status, body = post("/api/v1/gl-accounts", g)
|
||||||
|
ok(status in (200, 201), f"POST /gl-accounts '{g['code']}' → {status}: {body}")
|
||||||
|
ok("id" in body, f"gl-accounts response missing 'id': {body}")
|
||||||
|
GL_IDS[g["code"]] = int(body["id"])
|
||||||
|
|
||||||
def gl(category: str) -> dict:
|
# ── suite_revenue ─────────────────────────────────────────────────────────────
|
||||||
"""Resolve a CSV category name to {"id": int, "code": str} via _CSV_CATEGORY_TO_GL_CODE."""
|
|
||||||
code = _CSV_CATEGORY_TO_GL_CODE.get(category)
|
|
||||||
if code is None:
|
|
||||||
raise AssertionError(
|
|
||||||
f"No GL code for CSV category '{category}'. Add it to _CSV_CATEGORY_TO_GL_CODE."
|
|
||||||
)
|
|
||||||
acct = GL_ACCOUNTS.get(code)
|
|
||||||
if acct is None:
|
|
||||||
raise AssertionError(
|
|
||||||
f"GL '{code}' (for '{category}') missing — call seed_reference_data() first. "
|
|
||||||
f"Known: {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:
|
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")
|
s = Suite("Revenue — CSV + API round-trip")
|
||||||
|
|
||||||
if not api_available():
|
if not api_up():
|
||||||
s.run("API reachable", lambda: skip(f"Go API not running at {API_BASE}"))
|
s.run("API reachable", lambda: skip(f"Go API not running at {API_BASE}"))
|
||||||
return s
|
return s
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
# ── Step 1: load and validate CSV data ────────────────────────────────────
|
# 1. CSV validation
|
||||||
|
s.run("CSV loads", lambda: rows.extend(read_csv("revenue_budget_vs_actuals.csv")))
|
||||||
|
s.run("48 rows (24 months × 2 types)", lambda: eq(len(rows), 48))
|
||||||
|
s.run("only Product and Service types", lambda:
|
||||||
|
eq({r["revenue_type"] for r in rows}, {"Product", "Service"}))
|
||||||
|
s.run("variance = actual − budget", lambda: [
|
||||||
|
near(float(r["actual_amount"]) - float(r["budget_amount"]),
|
||||||
|
float(r["variance"]), tol=0.01, msg=f"variance wrong in {r['period']}")
|
||||||
|
for r in rows
|
||||||
|
])
|
||||||
|
|
||||||
def load():
|
# 2. Seed FK parents
|
||||||
rows.extend(read_csv("revenue_budget_vs_actuals.csv"))
|
s.run("seed departments + GL accounts", seed)
|
||||||
s.run("CSV loads without error", load)
|
|
||||||
|
|
||||||
s.run("CSV has 48 rows (24 months × 2 revenue types)", lambda:
|
# 3. POST budgets
|
||||||
assert_eq(len(rows), 48))
|
budget_ids: dict[str, int] = {}
|
||||||
|
|
||||||
s.run("CSV revenue types are Product and Service only", lambda:
|
def post_budget(rev_type: str):
|
||||||
assert_eq({r["revenue_type"] for r in rows}, {"Product", "Service"}))
|
row = next(r for r in rows if r["revenue_type"] == rev_type and r["period"] == "2023-01")
|
||||||
|
fy, fp = fiscal(row["period"]) # fiscal() already returns (int, int)
|
||||||
def check_csv_variance():
|
status, body = post("/api/v1/budgets", {
|
||||||
for r in rows:
|
"fiscal_year": int(fy),
|
||||||
diff = float(r["actual_amount"]) - float(r["budget_amount"])
|
"fiscal_period": int(fp),
|
||||||
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 (CreateBudgetRequest) ───────────────────────────
|
|
||||||
# version uses BudgetVersion consts: "original" | "forecast_1" | "forecast_2"
|
|
||||||
# department_id and gl_account_id are the integer IDs returned by seed step.
|
|
||||||
|
|
||||||
budget_ids: dict = {} # "revenue_type:period" → returned Budget.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": "original", # model.VersionOriginal
|
|
||||||
"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}")
|
|
||||||
assert_true(body.get("id") is not None, "Budget response missing 'id'")
|
|
||||||
budget_ids["Product:2023-01"] = body["id"]
|
|
||||||
|
|
||||||
s.run("POST /api/v1/budgets — Product revenue 2023-01 (version=original)", 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": "original",
|
"version": "original",
|
||||||
"department_id": DEPARTMENT_IDS["Revenue"],
|
"department_id": int(DEPT_IDS["Revenue"]),
|
||||||
"gl_account_id": gl("Service")["id"],
|
"gl_account_id": int(gl_id(rev_type)),
|
||||||
"amount": float(row["budget_amount"]),
|
"amount": float(row["budget_amount"]),
|
||||||
"currency": "USD",
|
"currency": "DKK",
|
||||||
"notes": "Service revenue budget — csv import",
|
"notes": f"{rev_type} budget — csv import",
|
||||||
"created_by": "test_suite",
|
"created_by": "test_suite",
|
||||||
}
|
})
|
||||||
status, body = http_post(f"{API_BASE}/api/v1/budgets", payload)
|
# 201 = created, 200 = upserted (row already existed — idempotent re-run)
|
||||||
assert_true(status in (200, 201),
|
ok(status in (200, 201), f"POST /budgets {rev_type} → {status}: {body}")
|
||||||
f"POST /budgets failed ({status}): {body}")
|
ok(body.get("id"), f"response missing 'id': {body}")
|
||||||
assert_true(body.get("id") is not None, "Budget response missing 'id'")
|
budget_ids[rev_type] = int(body["id"])
|
||||||
budget_ids["Service:2023-01"] = body["id"]
|
|
||||||
|
|
||||||
s.run("POST /api/v1/budgets — Service revenue 2023-01 (version=original)", post_service_budget)
|
s.run("POST budget — Product 2023-01", lambda: post_budget("Product"))
|
||||||
|
s.run("POST budget — Service 2023-01", lambda: post_budget("Service"))
|
||||||
# ── Step 4: POST actuals (IngestActualsRequest) ───────────────────────────
|
|
||||||
# IngestActualsRequest uses dept_code + gl_code strings (not integer IDs).
|
|
||||||
# The Go service resolves them to IDs internally.
|
|
||||||
|
|
||||||
|
# 4. POST actuals
|
||||||
def post_actuals():
|
def post_actuals():
|
||||||
period_rows = [r for r in rows if r["period"] == "2023-01"]
|
for r in [r for r in rows if r["period"] == "2023-01"]:
|
||||||
for r in period_rows:
|
fy, fp = fiscal(r["period"])
|
||||||
fy, fp = period_to_fiscal(r["period"])
|
status, body = post("/api/v1/actuals/ingest", {
|
||||||
account = gl(r["revenue_type"])
|
"fiscal_year": int(fy),
|
||||||
# IngestActualsRequest shape — matches model exactly
|
"fiscal_period": int(fp),
|
||||||
payload = {
|
"dept_code": "REV",
|
||||||
"fiscal_year": fy,
|
"gl_code": gl_code(r["revenue_type"]),
|
||||||
"fiscal_period": fp,
|
|
||||||
"dept_code": "REV", # Department.Code for Revenue
|
|
||||||
"gl_code": account["code"], # GLAccount.Code e.g. "4000"
|
|
||||||
"amount": float(r["actual_amount"]),
|
"amount": float(r["actual_amount"]),
|
||||||
"currency": "USD",
|
"currency": "DKK",
|
||||||
"source": "test_suite_csv",
|
"source": "test_suite",
|
||||||
}
|
})
|
||||||
status, body = http_post(f"{API_BASE}/api/v1/actuals/ingest", payload)
|
ok(status in (200, 201),
|
||||||
assert_true(status in (200, 201),
|
f"POST /actuals/ingest {r['revenue_type']} → {status}: {body}")
|
||||||
f"POST /actuals/ingest gl={account['code']} failed ({status}): {body}")
|
|
||||||
|
|
||||||
s.run("POST /api/v1/actuals/ingest — Product + Service 2023-01", post_actuals)
|
s.run("POST actuals — Product + Service 2023-01", post_actuals)
|
||||||
|
|
||||||
# ── Step 5: GET variance and verify VarianceReport shape ──────────────────
|
|
||||||
# Response: VarianceReport { department, fiscal_year, fiscal_period, version,
|
|
||||||
# currency, lines: []VarianceLine, total_budget, total_actual, total_variance, total_variance_pct }
|
|
||||||
# VarianceLine: { gl_account_id (actually gl_code string), gl_description, gl_type,
|
|
||||||
# budget, actual, variance_abs, variance_pct, status, currency }
|
|
||||||
|
|
||||||
|
# 5. GET variance — verify VarianceReport shape and values
|
||||||
report: dict = {}
|
report: dict = {}
|
||||||
|
|
||||||
def fetch_variance():
|
def fetch_report():
|
||||||
url = (f"{API_BASE}/api/v1/variance"
|
status, body = get("/api/v1/variance",
|
||||||
f"?fiscal_year=2023&fiscal_period=1&dept_code=REV&version=original")
|
fiscal_year=2023, fiscal_period=1, dept_code="REV", version="original")
|
||||||
status, body = http_get(url)
|
ok(status == 200, f"GET /variance → {status}: {body}")
|
||||||
assert_true(status == 200, f"GET /variance failed ({status}): {body}")
|
ok(isinstance(body, dict), "expected VarianceReport object")
|
||||||
|
ok("lines" in body, f"missing 'lines', got keys: {list(body.keys())}")
|
||||||
# Response is a VarianceReport object (not a list)
|
ok(len(body["lines"]) > 0, "lines is empty")
|
||||||
assert_true(isinstance(body, dict), f"Expected VarianceReport object, got: {type(body)}")
|
|
||||||
assert_true("lines" in body,
|
|
||||||
f"VarianceReport missing 'lines' key. Got keys: {list(body.keys())}")
|
|
||||||
assert_true(len(body["lines"]) > 0, "VarianceReport.lines is empty")
|
|
||||||
report.update(body)
|
report.update(body)
|
||||||
|
|
||||||
s.run("GET /api/v1/variance returns VarianceReport for REV dept 2023-01", fetch_variance)
|
s.run("GET /variance returns VarianceReport", fetch_report)
|
||||||
|
|
||||||
def verify_report_top_level():
|
def verify_report():
|
||||||
assert_eq(report.get("fiscal_year"), 2023, "fiscal_year mismatch")
|
eq(report["fiscal_year"], 2023, "fiscal_year")
|
||||||
assert_eq(report.get("fiscal_period"), 1, "fiscal_period mismatch")
|
eq(report["fiscal_period"], 1, "fiscal_period")
|
||||||
assert_eq(report.get("version"), "original", "version mismatch")
|
eq(report["version"], "original", "version")
|
||||||
assert_true(report.get("department") is not None, "department field missing")
|
for field in ("department", "currency", "total_budget", "total_actual"):
|
||||||
assert_true(report.get("currency") is not None, "currency field missing")
|
ok(field in report, f"VarianceReport missing '{field}'")
|
||||||
assert_true(report.get("total_budget") is not None, "total_budget field missing")
|
|
||||||
assert_true(report.get("total_actual") is not None, "total_actual field missing")
|
|
||||||
|
|
||||||
s.run("VarianceReport top-level fields present", verify_report_top_level)
|
for line in report["lines"]:
|
||||||
|
for f in ("gl_account_id", "gl_description", "gl_type",
|
||||||
|
"budget", "actual", "variance_abs", "status"):
|
||||||
|
ok(f in line, f"VarianceLine missing '{f}'")
|
||||||
|
ok(line["status"] in ("favourable", "unfavourable", "on_budget"),
|
||||||
|
f"bad status: {line['status']}")
|
||||||
|
|
||||||
def verify_variance_lines():
|
s.run("VarianceReport shape valid", verify_report)
|
||||||
lines = report.get("lines", [])
|
|
||||||
# VarianceLine fields: gl_account_id (gl_code string), gl_description,
|
|
||||||
# gl_type, budget, actual, variance_abs, variance_pct, status, currency
|
|
||||||
for line in lines:
|
|
||||||
assert_true("gl_account_id" in line, f"VarianceLine missing gl_account_id: {line}")
|
|
||||||
assert_true("gl_description" in line, f"VarianceLine missing gl_description: {line}")
|
|
||||||
assert_true("gl_type" in line, f"VarianceLine missing gl_type: {line}")
|
|
||||||
assert_true("budget" in line, f"VarianceLine missing budget: {line}")
|
|
||||||
assert_true("actual" in line, f"VarianceLine missing actual: {line}")
|
|
||||||
assert_true("variance_abs" in line, f"VarianceLine missing variance_abs: {line}")
|
|
||||||
assert_true("status" in line, f"VarianceLine missing status: {line}")
|
|
||||||
# status must be a valid VarianceStatus
|
|
||||||
assert_true(line["status"] in ("favourable", "unfavourable", "on_budget"),
|
|
||||||
f"Unknown VarianceStatus: {line['status']}")
|
|
||||||
|
|
||||||
s.run("VarianceLine fields and status values valid", verify_variance_lines)
|
|
||||||
|
|
||||||
def verify_product_line():
|
def verify_product_line():
|
||||||
csv_row = next(r for r in rows
|
csv_row = next(r for r in rows
|
||||||
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
|
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_var = float(csv_row["variance"])
|
|
||||||
|
|
||||||
# VarianceLine.gl_account_id holds the GL code string (e.g. "4000")
|
|
||||||
line = next((l for l in report["lines"]
|
line = next((l for l in report["lines"]
|
||||||
if l.get("gl_account_id") == gl("Product")["code"]), None)
|
if l["gl_account_id"] == "4000"), None)
|
||||||
assert_true(line is not None,
|
ok(line, f"no line for gl_account_id=4000, got: {[l['gl_account_id'] for l in report['lines']]}")
|
||||||
f"No VarianceLine for Product (gl_code={gl('Product')['code']}). "
|
near(float(line["budget"]), float(csv_row["budget_amount"]), msg="budget")
|
||||||
f"Lines: {[l.get('gl_account_id') for l in report['lines']]}")
|
near(float(line["actual"]), float(csv_row["actual_amount"]), msg="actual")
|
||||||
|
near(float(line["variance_abs"]), abs(float(csv_row["variance"])), msg="variance_abs")
|
||||||
assert_true(abs(float(line["budget"]) - expected_budget) < 1.0,
|
|
||||||
f"budget mismatch: got {line['budget']} expected {expected_budget:.2f}")
|
|
||||||
assert_true(abs(float(line["actual"]) - expected_actual) < 1.0,
|
|
||||||
f"actual mismatch: got {line['actual']} expected {expected_actual:.2f}")
|
|
||||||
assert_true(abs(float(line["variance_abs"]) - abs(expected_var)) < 1.0,
|
|
||||||
f"variance_abs mismatch: got {line['variance_abs']} expected {abs(expected_var):.2f}")
|
|
||||||
# Revenue account: favour_high=True, so over-actual should be favourable
|
|
||||||
if float(csv_row["actual_amount"]) >= float(csv_row["budget_amount"]):
|
if float(csv_row["actual_amount"]) >= float(csv_row["budget_amount"]):
|
||||||
assert_eq(line["status"], "favourable",
|
eq(line["status"], "favourable", "revenue over-budget should be favourable")
|
||||||
f"Revenue over-budget should be favourable, got {line['status']}")
|
|
||||||
|
|
||||||
s.run("VarianceLine values match CSV for Product 2023-01", verify_product_line)
|
s.run("Product line values match CSV", verify_product_line)
|
||||||
|
|
||||||
def verify_totals():
|
s.run("totals = sum of lines", lambda: (
|
||||||
# total_budget and total_actual should be sum of the lines
|
near(float(report["total_budget"]),
|
||||||
line_budget_sum = sum(float(l["budget"]) for l in report["lines"])
|
sum(float(l["budget"]) for l in report["lines"]), msg="total_budget"),
|
||||||
line_actual_sum = sum(float(l["actual"]) for l in report["lines"])
|
near(float(report["total_actual"]),
|
||||||
assert_true(abs(float(report["total_budget"]) - line_budget_sum) < 1.0,
|
sum(float(l["actual"]) for l in report["lines"]), msg="total_actual"),
|
||||||
f"total_budget {report['total_budget']} != sum of lines {line_budget_sum:.2f}")
|
))
|
||||||
assert_true(abs(float(report["total_actual"]) - line_actual_sum) < 1.0,
|
|
||||||
f"total_actual {report['total_actual']} != sum of lines {line_actual_sum:.2f}")
|
|
||||||
|
|
||||||
s.run("VarianceReport totals equal sum of lines", verify_totals)
|
|
||||||
|
|
||||||
# ── Step 6: GET /variance/alerts and verify AlertThreshold shape ──────────
|
|
||||||
# AlertThreshold: { gl_code, description, budget, actual, variance_pct, status, department }
|
|
||||||
|
|
||||||
|
# 6. Alerts shape check
|
||||||
def check_alerts():
|
def check_alerts():
|
||||||
url = f"{API_BASE}/api/v1/variance/alerts?fiscal_year=2023&fiscal_period=1"
|
status, body = get("/api/v1/variance/alerts", fiscal_year=2023, fiscal_period=1)
|
||||||
status, body = http_get(url)
|
ok(status == 200, f"GET /variance/alerts → {status}: {body}")
|
||||||
assert_true(status == 200, f"GET /variance/alerts failed ({status}): {body}")
|
|
||||||
|
|
||||||
alerts = body if isinstance(body, list) else body.get("alerts", [])
|
alerts = body if isinstance(body, list) else body.get("alerts", [])
|
||||||
# Alerts may be empty if nothing breaches threshold — just validate shape if any exist
|
for a in alerts:
|
||||||
for alert in alerts:
|
for f in ("gl_code", "description", "budget", "actual", "variance_pct", "status", "department"):
|
||||||
assert_true("gl_code" in alert, f"AlertThreshold missing gl_code: {alert}")
|
ok(f in a, f"AlertThreshold missing '{f}'")
|
||||||
assert_true("description" in alert, f"AlertThreshold missing description: {alert}")
|
ok(a["status"] in ("favourable", "unfavourable", "on_budget"),
|
||||||
assert_true("budget" in alert, f"AlertThreshold missing budget: {alert}")
|
f"bad alert status: {a['status']}")
|
||||||
assert_true("actual" in alert, f"AlertThreshold missing actual: {alert}")
|
|
||||||
assert_true("variance_pct" in alert, f"AlertThreshold missing variance_pct: {alert}")
|
|
||||||
assert_true("status" in alert, f"AlertThreshold missing status: {alert}")
|
|
||||||
assert_true("department" in alert, f"AlertThreshold missing department: {alert}")
|
|
||||||
assert_true(alert["status"] in ("favourable", "unfavourable", "on_budget"),
|
|
||||||
f"Unknown alert status: {alert['status']}")
|
|
||||||
|
|
||||||
s.run("GET /api/v1/variance/alerts returns valid AlertThreshold list", check_alerts)
|
s.run("GET /variance/alerts shape valid", check_alerts)
|
||||||
|
|
||||||
# ── Step 7: update budget to forecast_1 and verify variance shifts ────────
|
# 7. Update to forecast_1 and verify variance shifts
|
||||||
# PUT /api/v1/budgets/{id} with version="forecast_1" (model.VersionForecast1)
|
def update_forecast():
|
||||||
|
bid = budget_ids.get("Product")
|
||||||
def update_and_reverify():
|
if not bid:
|
||||||
budget_id = budget_ids.get("Product:2023-01")
|
skip("no Product budget id — skipping")
|
||||||
if not budget_id:
|
|
||||||
skip("Product budget id not captured — skipping update test")
|
|
||||||
|
|
||||||
csv_row = next(r for r in rows
|
csv_row = next(r for r in rows
|
||||||
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
|
if r["revenue_type"] == "Product" and r["period"] == "2023-01")
|
||||||
revised_amount = float(csv_row["budget_amount"]) * 1.10 # +10% reforecast
|
revised = float(csv_row["budget_amount"]) * 1.10
|
||||||
|
|
||||||
fy, fp = period_to_fiscal("2023-01")
|
fy, fp = fiscal("2023-01")
|
||||||
status, body = http_put(
|
status, body = put(f"/api/v1/budgets/{bid}", {
|
||||||
f"{API_BASE}/api/v1/budgets/{budget_id}",
|
"fiscal_year": int(fy),
|
||||||
{
|
"fiscal_period": int(fp),
|
||||||
"fiscal_year": fy,
|
"version": "forecast_1",
|
||||||
"fiscal_period": fp,
|
"department_id": int(DEPT_IDS["Revenue"]),
|
||||||
"version": "forecast_1", # model.VersionForecast1
|
"gl_account_id": int(gl_id("Product")),
|
||||||
"department_id": DEPARTMENT_IDS["Revenue"],
|
"amount": float(revised),
|
||||||
"gl_account_id": gl("Product")["id"],
|
"currency": "DKK",
|
||||||
"amount": revised_amount,
|
|
||||||
"currency": "USD",
|
|
||||||
"notes": "Q1 reforecast +10%",
|
"notes": "Q1 reforecast +10%",
|
||||||
"created_by": "test_suite",
|
"created_by": "test_suite",
|
||||||
}
|
})
|
||||||
)
|
ok(status in (200, 204), f"PUT /budgets/{bid} → {status}: {body}")
|
||||||
assert_true(status in (200, 204),
|
|
||||||
f"PUT /budgets/{budget_id} failed ({status}): {body}")
|
|
||||||
|
|
||||||
# Re-fetch with version=forecast_1 — budget should reflect revision
|
|
||||||
url = (f"{API_BASE}/api/v1/variance"
|
|
||||||
f"?fiscal_year=2023&fiscal_period=1&dept_code=REV&version=forecast_1")
|
|
||||||
status2, body2 = http_get(url)
|
|
||||||
assert_true(status2 == 200, f"Re-fetch forecast_1 variance failed ({status2}): {body2}")
|
|
||||||
|
|
||||||
|
status2, body2 = get("/api/v1/variance",
|
||||||
|
fiscal_year=2023, fiscal_period=1, dept_code="REV", version="forecast_1")
|
||||||
|
ok(status2 == 200, f"GET forecast_1 variance → {status2}")
|
||||||
lines = body2.get("lines", []) if isinstance(body2, dict) else []
|
lines = body2.get("lines", []) if isinstance(body2, dict) else []
|
||||||
line = next((l for l in lines
|
line = next((l for l in lines if l.get("gl_account_id") == "4000"), None)
|
||||||
if l.get("gl_account_id") == gl("Product")["code"]), None)
|
ok(line, "Product line missing in forecast_1 variance")
|
||||||
assert_true(line is not None,
|
near(float(line["budget"]), revised, msg="forecast_1 budget not updated")
|
||||||
"Product line missing from forecast_1 variance report after update")
|
|
||||||
assert_true(abs(float(line["budget"]) - revised_amount) < 1.0,
|
|
||||||
f"forecast_1 budget not reflected: got {line['budget']} expected {revised_amount:.2f}")
|
|
||||||
|
|
||||||
s.run("PUT /api/v1/budgets/{id} version=forecast_1 — variance shifts correctly", update_and_reverify)
|
s.run("PUT budget forecast_1 — variance reflects revision", update_forecast)
|
||||||
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
# ── CSV-only suites ───────────────────────────────────────────────────────────
|
||||||
# ── Remaining CSV-only suites (unchanged) ─────────────────────────────────────
|
|
||||||
|
|
||||||
def suite_opex() -> Suite:
|
def suite_opex() -> Suite:
|
||||||
s = Suite("Opex CSV")
|
s = Suite("Opex CSV")
|
||||||
rows = []
|
rows = []
|
||||||
|
s.run("loads", lambda: rows.extend(read_csv("opex_budget_vs_actuals.csv")))
|
||||||
def load():
|
s.run("has rows", lambda: ok(len(rows) > 0))
|
||||||
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:
|
s.run("all departments present", lambda:
|
||||||
assert_true(
|
ok({"Engineering","Sales","Marketing","Operations"}.issubset({r["department"] for r in rows})))
|
||||||
{"Engineering", "Sales", "Marketing", "Operations"}.issubset(
|
s.run("no zero budgets", lambda: [
|
||||||
{r["department"] for r in rows}
|
ok(float(r["budget_amount"]) > 0, f"zero budget: {r['department']}/{r['category']}")
|
||||||
)))
|
for r in rows
|
||||||
|
])
|
||||||
def check_no_zero():
|
s.run("variance sign correct", lambda: [
|
||||||
for r in rows:
|
eq(1 if float(r["variance"]) > 0 else (-1 if float(r["variance"]) < 0 else 0),
|
||||||
assert_true(float(r["budget_amount"]) > 0,
|
1 if float(r["actual_amount"]) > float(r["budget_amount"]) else
|
||||||
f"Zero budget: {r['department']} / {r['category']}")
|
(-1 if float(r["actual_amount"]) < float(r["budget_amount"]) else 0),
|
||||||
s.run("no zero budget amounts", check_no_zero)
|
f"sign wrong {r['period']} {r['category']}")
|
||||||
|
for r in rows
|
||||||
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
|
return s
|
||||||
|
|
||||||
|
|
||||||
def suite_pl() -> Suite:
|
def suite_pl() -> Suite:
|
||||||
s = Suite("P&L CSV")
|
s = Suite("P&L CSV")
|
||||||
rows = []
|
rows = []
|
||||||
|
s.run("loads", lambda: rows.extend(read_csv("pl_income_statement.csv")))
|
||||||
def load():
|
s.run("24 rows", lambda: eq(len(rows), 24))
|
||||||
rows.extend(read_csv("pl_income_statement.csv"))
|
s.run("total_revenue = product + service", lambda: [
|
||||||
s.run("loads without error", load)
|
near(float(r["product_revenue"]) + float(r["service_revenue"]),
|
||||||
|
float(r["total_revenue"]), tol=0.05, msg=r["period"])
|
||||||
s.run("exactly 24 rows", lambda: assert_eq(len(rows), 24))
|
for r in rows
|
||||||
|
])
|
||||||
def check_rev_sum():
|
s.run("gross_profit = revenue − cogs", lambda: [
|
||||||
for r in rows:
|
near(float(r["total_revenue"]) - float(r["total_cogs"]),
|
||||||
total = float(r["product_revenue"]) + float(r["service_revenue"])
|
float(r["gross_profit"]), tol=0.05, msg=r["period"])
|
||||||
assert_true(abs(total - float(r["total_revenue"])) < 0.05,
|
for r in rows
|
||||||
f"Revenue sum mismatch in {r['period']}")
|
])
|
||||||
s.run("total_revenue = product + service", check_rev_sum)
|
s.run("gross margin 30–90%", lambda: [
|
||||||
|
ok(30 <= float(r["gross_margin_pct"]) <= 90, f"{r['gross_margin_pct']}% in {r['period']}")
|
||||||
def check_gross():
|
for r in rows
|
||||||
for r in rows:
|
])
|
||||||
gp = float(r["total_revenue"]) - float(r["total_cogs"])
|
s.run("ebitda = gross_profit − opex", lambda: [
|
||||||
assert_true(abs(gp - float(r["gross_profit"])) < 0.05,
|
near(float(r["gross_profit"]) - float(r["total_opex"]),
|
||||||
f"Gross profit mismatch in {r['period']}")
|
float(r["ebitda"]), tol=0.05, msg=r["period"])
|
||||||
s.run("gross_profit = revenue − cogs", check_gross)
|
for r in rows
|
||||||
|
])
|
||||||
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
|
return s
|
||||||
|
|
||||||
|
|
||||||
def suite_cashflow() -> Suite:
|
def suite_cashflow() -> Suite:
|
||||||
s = Suite("Cash Flow CSV")
|
s = Suite("Cash Flow CSV")
|
||||||
rows = []
|
rows = []
|
||||||
|
s.run("loads", lambda: rows.extend(read_csv("cash_flow.csv")))
|
||||||
def load():
|
s.run("24 rows", lambda: eq(len(rows), 24))
|
||||||
rows.extend(read_csv("cash_flow.csv"))
|
s.run("net_change = op + inv + fin", lambda: [
|
||||||
s.run("loads without error", load)
|
near(float(r["net_operating_cash_flow"]) +
|
||||||
|
|
||||||
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_investing_cash_flow"]) +
|
||||||
float(r["net_financing_cash_flow"]))
|
float(r["net_financing_cash_flow"]),
|
||||||
assert_true(abs(total - float(r["net_change_in_cash"])) < 0.05,
|
float(r["net_change_in_cash"]), tol=0.05, msg=r["period"])
|
||||||
f"Cash flow sum mismatch in {r['period']}")
|
for r in rows
|
||||||
s.run("net_change = operating + investing + financing", check_net)
|
])
|
||||||
|
s.run("positive cash pre Series A", lambda: [
|
||||||
def check_pre_series_a():
|
ok(float(r["closing_cash_balance"]) > 0, f"negative cash in {r['period']}")
|
||||||
for r in rows:
|
for r in rows if r["period"] <= "2023-05"
|
||||||
if r["period"] <= "2023-05":
|
])
|
||||||
assert_true(float(r["closing_cash_balance"]) > 0,
|
s.run("Series A in 2023-06", lambda: (
|
||||||
f"Negative cash too early: {r['period']}")
|
lambda june=next((r for r in rows if r["period"] == "2023-06"), None): (
|
||||||
s.run("cash balance positive before Series A (pre 2023-06)", check_pre_series_a)
|
ok(june is not None, "2023-06 missing"),
|
||||||
|
ok(float(june["equity_raised"]) > 0, "Series A not recorded"),
|
||||||
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
|
return s
|
||||||
|
|
||||||
|
|
||||||
def suite_headcount() -> Suite:
|
def suite_headcount() -> Suite:
|
||||||
s = Suite("Headcount CSV")
|
s = Suite("Headcount CSV")
|
||||||
rows = []
|
rows = []
|
||||||
|
s.run("loads", lambda: rows.extend(read_csv("headcount_workforce.csv")))
|
||||||
def load():
|
s.run("has rows", lambda: ok(len(rows) > 0))
|
||||||
rows.extend(read_csv("headcount_workforce.csv"))
|
s.run("valid statuses", lambda:
|
||||||
s.run("loads without error", load)
|
ok({r["status"] for r in rows}.issubset({"Active", "Terminated"})))
|
||||||
|
s.run("FTE 0–1", lambda: [
|
||||||
s.run("has rows", lambda: assert_true(len(rows) > 0))
|
ok(0 < float(r["headcount_fte"]) <= 1.0, f"FTE={r['headcount_fte']} for {r['employee_id']}")
|
||||||
|
for r in rows
|
||||||
s.run("status only Active or Terminated", lambda:
|
])
|
||||||
assert_true({r["status"] for r in rows}.issubset({"Active", "Terminated"})))
|
s.run("salaries positive", lambda: [
|
||||||
|
ok(float(r["annual_salary_budget"]) > 0) for r in rows
|
||||||
def check_fte():
|
])
|
||||||
for r in rows:
|
s.run("headcount grows Jan→Dec", lambda: ok(
|
||||||
fte = float(r["headcount_fte"])
|
len([r for r in rows if r["period"] == "2024-12" and r["status"] == "Active"]) >=
|
||||||
assert_true(0 < fte <= 1.0,
|
len([r for r in rows if r["period"] == "2023-01" and r["status"] == "Active"])
|
||||||
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
|
return s
|
||||||
|
|
||||||
|
|
||||||
# ── Reporter ──────────────────────────────────────────────────────────────────
|
# ── Reporter ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
PASS = "\033[32m✓\033[0m"
|
def run_suites(suites):
|
||||||
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
|
total = passed = failed = skipped = 0
|
||||||
|
|
||||||
for suite in suites:
|
for suite in suites:
|
||||||
print(f"\n{BOLD}{suite.name}{RESET}")
|
print(f"\n\033[1m{suite.name}\033[0m")
|
||||||
for r in suite.results:
|
for r in suite.results:
|
||||||
total += 1
|
total += 1
|
||||||
if r.skipped:
|
if r.skipped:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
print(f" {SKIP} {r.name}")
|
print(f" \033[33m⊘\033[0m {r.name}")
|
||||||
print(f" {r.message}")
|
print(f" {r.message}")
|
||||||
elif r.passed:
|
elif r.passed:
|
||||||
passed += 1
|
passed += 1
|
||||||
print(f" {PASS} {r.name}")
|
print(f" \033[32m✓\033[0m {r.name}")
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
print(f" {FAIL} {r.name}")
|
print(f" \033[31m✗\033[0m {r.name}")
|
||||||
for line in r.message.strip().splitlines():
|
for line in r.message.strip().splitlines():
|
||||||
print(f" {line}")
|
print(f" {line}")
|
||||||
|
|
||||||
print(f"\n{'─' * 48}")
|
print(f"\n{'─'*48}")
|
||||||
color = "\033[32m" if failed == 0 else "\033[31m"
|
color = "\033[32m" if failed == 0 else "\033[31m"
|
||||||
print(f"{color}{BOLD}{passed}/{total} passed{RESET}"
|
print(f"{color}\033[1m{passed}/{total} passed\033[0m"
|
||||||
+ (f" {SKIP} {skipped} skipped" if skipped else "")
|
+ (f" \033[33m⊘\033[0m {skipped} skipped" if skipped else "")
|
||||||
+ (f" {FAIL} {failed} failed" if failed else ""))
|
+ (f" \033[31m✗\033[0m {failed} failed" if failed else ""))
|
||||||
print()
|
print()
|
||||||
return failed == 0
|
return failed == 0
|
||||||
|
|
||||||
@@ -718,24 +461,17 @@ def run_suites(suites: List[Suite]) -> bool:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="FP&A test suite")
|
||||||
parser = argparse.ArgumentParser(description="FP&A test suite — no dependencies needed")
|
parser.add_argument("--url", default=None, help="API base URL")
|
||||||
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.url:
|
if args.url:
|
||||||
API_BASE = args.url
|
API_BASE = args.url
|
||||||
|
|
||||||
# suite_revenue always runs — it skips the API steps gracefully if not available
|
ok = run_suites([
|
||||||
suites = [
|
|
||||||
suite_revenue(),
|
suite_revenue(),
|
||||||
suite_opex(),
|
suite_opex(),
|
||||||
suite_pl(),
|
suite_pl(),
|
||||||
suite_cashflow(),
|
suite_cashflow(),
|
||||||
suite_headcount(),
|
suite_headcount(),
|
||||||
]
|
])
|
||||||
|
|
||||||
ok = run_suites(suites)
|
|
||||||
sys.exit(0 if ok else 1)
|
sys.exit(0 if ok else 1)
|
||||||
Reference in New Issue
Block a user