fastapi-gsap/tests/test_functions.py
Tyler J King 0c77943ceb feat: governed function runtime + billing drain
GCAP-SPEC-FUNCTION-DESCRIPTOR-0001 implementation.
Mirrors connector runtime pattern exactly.

FunctionPlugin — trigger_events, handle(), descriptor(), knative_manifest()
FunctionRegistry — trigger_index for event-driven routing
FunctionRuntime — invoke() + dispatch() with Chronicle lineage
governed_function decorator — SDK surface for function authors
BillingProcessor — GSAP_CR_RECEIVED → billable event with Chronicle CID
EchoFunction — dev/test

API: /functions/ catalogue, invoke, dispatch, manifest, health
8 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:12:29 -04:00

152 lines
5.1 KiB
Python

"""Tests for governed serverless function module."""
import pytest
@pytest.mark.asyncio
async def test_function_catalogue(client):
resp = await client.get("/functions/")
assert resp.status_code == 200
body = resp.json()
ids = [f["function_id"] for f in body["functions"]]
assert "billing-processor" in ids
assert "echo-function" in ids
assert "trigger_index" in body
@pytest.mark.asyncio
async def test_function_descriptor(client):
resp = await client.get("/functions/billing-processor/")
assert resp.status_code == 200
desc = resp.json()
assert desc["function_id"] == "billing-processor"
assert "trigger_events" in desc
assert "GSAP_CR_RECEIVED" in desc["trigger_events"]
assert desc["corpus_entry_cid"].startswith("sha256:")
@pytest.mark.asyncio
async def test_knative_manifest(client):
resp = await client.get("/functions/billing-processor/manifest/?image=gcr.io/test/billing:v1")
assert resp.status_code == 200
manifest = resp.json()
assert manifest["kind"] == "Service"
assert manifest["apiVersion"] == "serving.knative.dev/v1"
assert manifest["metadata"]["name"] == "billing-processor"
containers = manifest["spec"]["template"]["spec"]["containers"]
assert containers[0]["image"] == "gcr.io/test/billing:v1"
@pytest.mark.asyncio
async def test_invoke_echo(client):
resp = await client.post(
"/functions/echo-function/invoke/",
json={"event": {"msg": "hello", "value": 42}},
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["msg"] == "hello"
assert body["data"]["value"] == 42
assert body["function_id"] == "echo-function"
assert body["duration_ms"] >= 0
@pytest.mark.asyncio
async def test_billing_completed(client):
resp = await client.post(
"/functions/billing-processor/invoke/",
json={
"event": {
"outcome": "completed",
"event_type": "gsap_cr_completed",
"quantity": 2,
"chronicle_cid": "sha256:abc123",
},
},
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["billed"] is True
assert body["data"]["amount"] == 0.01 # 0.005 * 2
assert body["data"]["chronicle_cid"] == "sha256:abc123"
@pytest.mark.asyncio
async def test_billing_failed_not_billed(client):
resp = await client.post(
"/functions/billing-processor/invoke/",
json={
"event": {
"outcome": "failed",
"event_type": "gsap_cr_completed",
},
},
)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["data"]["billed"] is False
@pytest.mark.asyncio
async def test_dispatch_routes_to_billing(client):
resp = await client.post(
"/functions/dispatch/",
json={
"event_kind": "GSAP_CR_RECEIVED",
"event_data": {
"outcome": "completed",
"event_type": "gsap_cr_completed",
"quantity": 1,
},
"trigger_cid": "sha256:trigger001",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["event_kind"] == "GSAP_CR_RECEIVED"
assert body["dispatched_count"] >= 1
# billing-processor should be in the results
billing_results = [r for r in body["results"] if r["function_id"] == "billing-processor"]
assert len(billing_results) == 1
assert billing_results[0]["success"] is True
@pytest.mark.asyncio
async def test_gsap_required_without_ac(client):
"""A gsap_required function invoked without gsap_context_id returns error."""
# We need a function with gsap_required=True. Register one via the runtime.
# Use the billing-processor with gsap_required toggled, or invoke directly.
# Since billing-processor has gsap_required=False, we test via the runtime directly.
from gsap_broker.functions.base import FunctionContext, FunctionPlugin, FunctionResult
from gsap_broker.functions.registry import FunctionRegistry
from gsap_broker.functions.runtime import FunctionRuntime
from typing import Any
class StrictFunction(FunctionPlugin):
function_id = "strict-func"
corpus_entry_cid = "sha256:" + "s" * 64
capability_mask = 1
trigger_events = []
gsap_required = True
chronicle_enabled = False
max_duration_seconds = 5
display_name = "Strict"
description = "Requires GSAP"
version = "0.1.0"
async def handle(self, event: dict[str, Any], context: FunctionContext) -> FunctionResult:
return FunctionResult(success=True, data=event)
def health_check(self) -> bool:
return True
reg = FunctionRegistry()
reg.register(StrictFunction())
rt = FunctionRuntime(registry=reg)
ctx = FunctionContext() # No gsap_context_id
result = await rt.invoke("strict-func", {"test": True}, ctx)
assert result.success is False
assert "GSAP context required" in (result.error or "")