add fixture-invoicing example, sample-room wrap, kind cluster support

- examples/fixture-invoicing/: FastAPI + Vue + Postgres demo (4-entity invoice fixture)
- cfg/sample/: wraps the fixture (managed.repos points at examples/)
- ctrl/kind-{up,down,status}.sh + per-room k8s render in soleprint/ctrl/k8s/
- build.py: relative repo paths, resilient rmtree, optional k8s render hook
- cfg/.gitignore: stop ignoring sample/ and standalone/ template rooms

Manifests render cleanly but kind cluster has not been run end-to-end yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 05:30:52 -03:00
parent b886455431
commit 5f9cac1947
78 changed files with 3022 additions and 198 deletions

View File

@@ -16,6 +16,7 @@ NETWORK_NAME=sample_network
# PORTS (unique per room)
# =============================================================================
SOLEPRINT_PORT=12030
NGINX_PORT=8130
# =============================================================================
# GOOGLE OAUTH

View File

@@ -0,0 +1,44 @@
# Fixture Invoicing — Soleprint Demo
> **This book describes a deliberately-fake invoicing app used as a test
> fixture for the Soleprint framework.** It is not a real product.
## Purpose
The fixture exercises every soleprint tool end-to-end so we can iterate on
the framework without needing a real managed app. See
[`examples/fixture-invoicing/`](../../../../../../examples/fixture-invoicing/)
for the app itself.
## Data model
```
Customer ──< Invoice ──< LineItem
└──────< Payment
```
| Table | Key fields |
|-------------|-------------------------------------------------|
| `customer` | id · name · email · created_at |
| `invoice` | id · number · customer_id · issued_at · status |
| `line_item` | id · invoice_id · description · qty · unit_price|
| `payment` | id · invoice_id · amount · method · paid_at |
## Happy-path flow
1. Create a customer (`POST /api/customers`)
2. Create a draft invoice for them (`POST /api/invoices`)
3. Add one or more line items (`POST /api/line-items/invoices/{id}`)
4. Record a payment (`POST /api/payments/invoices/{id}`) — if the total
paid ≥ total billed, the invoice auto-transitions to `paid`.
## How this connects to Soleprint
| Soleprint tool | Fixture hook |
|----------------|--------------|
| datagen | `station/tools/datagen/fixture.py` — FixtureInvoicingGenerator |
| graphgen | `station/tools/graphgen/schema.json` — 4 models + FK edges |
| databrowse | `station/tools/databrowse/depot/{schema,views}.json` |
| tester | `station/tools/tester/tests/fixture/` |
| link | SQLAlchemy reflection against `customer`, `invoice`, etc. |
| sbwrapper | Injected into fixture's Vue frontend by nginx |

View File

@@ -21,7 +21,7 @@ services:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_nginx
ports:
- "80:80"
- "${NGINX_PORT:-80}:80"
volumes:
- ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro
networks:

View File

@@ -1,34 +1,40 @@
# Sample Room - Nginx Config for Docker
#
# This config uses docker service names (soleprint, frontend, backend)
# which resolve within the docker network.
# Sample Room Nginx Config
# Uses Docker DNS resolver so upstreams resolve at request time
# (lets nginx start before backend/frontend containers are up).
# sample.spr.local.ar - frontend with soleprint sidebar
resolver 127.0.0.11 valid=10s;
# sample.spr.local.ar — fixture wrapped by soleprint (sidebar injected)
server {
listen 80;
server_name sample.spr.local.ar;
# Soleprint routes - sidebar API and assets
set $soleprint sample_spr;
set $backend sample_backend;
set $frontend sample_frontend;
# Soleprint routes (sidebar API + static assets)
location /spr/ {
proxy_pass http://soleprint:8000/;
rewrite ^/spr/(.*)$ /$1 break;
proxy_pass http://$soleprint:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API (uncomment if your app has a backend)
# location /api/ {
# proxy_pass http://backend:8000/api/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# Fixture backend API
location /api/ {
proxy_pass http://$backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend with sidebar injection
# Frontend (Vite dev server on 5173) with sidebar injection
location / {
proxy_pass http://frontend:80;
proxy_pass http://$frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -38,29 +44,29 @@ server {
proxy_set_header Connection "upgrade";
proxy_set_header Accept-Encoding "";
# Inject sidebar CSS and JS into head
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
sub_filter_once off;
sub_filter_types text/html;
}
}
# sample.local.ar - frontend without sidebar (direct access)
# sample.local.ar — direct fixture access (no sidebar)
server {
listen 80;
server_name sample.local.ar;
# Backend API (uncomment if your app has a backend)
# location /api/ {
# proxy_pass http://backend:8000/api/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
set $backend sample_backend;
set $frontend sample_frontend;
location /api/ {
proxy_pass http://$backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://frontend:80;
proxy_pass http://$frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -0,0 +1,60 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Fixture Invoicing Data Model",
"description": "Tables of the fixture-invoicing app — all data is obviously placeholder.",
"version": "0.1.0",
"meta": {
"purpose": "Browse and QA fixture data while iterating on soleprint tools.",
"modes": ["sql"]
},
"definitions": {
"Customer": {
"type": "object",
"description": "A fixture customer.",
"table": "customer",
"properties": {
"id": {"type": "integer", "column": "id"},
"name": {"type": "string", "column": "name"},
"email": {"type": "string", "column": "email"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
}
},
"Invoice": {
"type": "object",
"description": "An invoice issued to a fixture customer.",
"table": "invoice",
"properties": {
"id": {"type": "integer", "column": "id"},
"number": {"type": "string", "column": "number"},
"customer_id": {"type": "integer", "column": "customer_id", "fk": "Customer"},
"issued_at": {"type": "string", "format": "date-time", "column": "issued_at"},
"due_at": {"type": "string", "format": "date-time", "column": "due_at"},
"status": {"type": "string", "column": "status"}
}
},
"LineItem": {
"type": "object",
"description": "A billable line on an invoice.",
"table": "line_item",
"properties": {
"id": {"type": "integer", "column": "id"},
"invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"},
"description": {"type": "string", "column": "description"},
"quantity": {"type": "integer", "column": "quantity"},
"unit_price": {"type": "number", "column": "unit_price"}
}
},
"Payment": {
"type": "object",
"description": "A payment recorded against an invoice.",
"table": "payment",
"properties": {
"id": {"type": "integer", "column": "id"},
"invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"},
"amount": {"type": "number", "column": "amount"},
"method": {"type": "string", "column": "method"},
"paid_at": {"type": "string", "format": "date-time", "column": "paid_at"}
}
}
}
}

View File

@@ -0,0 +1,57 @@
{
"views": [
{
"name": "customers",
"title": "Customers",
"slug": "customers",
"description": "All fixture customers.",
"mode": "sql",
"entity": "Customer",
"order_by": "id ASC",
"fields": ["id", "name", "email", "created_at"],
"display_fields": {
"id": {"label": "ID", "width": "60px"},
"name": {"label": "Name", "width": "200px", "primary": true},
"email": {"label": "Email", "width": "220px"},
"created_at": {"label": "Created", "width": "180px"}
}
},
{
"name": "invoices_by_status",
"title": "Invoices by Status",
"slug": "invoices-by-status",
"description": "Invoices grouped by lifecycle status.",
"mode": "sql",
"entity": "Invoice",
"group_by": "status",
"order_by": "issued_at DESC",
"fields": ["id", "number", "customer_id", "issued_at", "due_at", "status"],
"display_fields": {
"number": {"label": "Number", "width": "150px", "primary": true},
"status": {"label": "Status", "width": "100px"},
"issued_at": {"label": "Issued", "width": "140px"},
"due_at": {"label": "Due", "width": "140px"}
}
},
{
"name": "line_items",
"title": "Line Items",
"slug": "line-items",
"description": "All line items across fixture invoices.",
"mode": "sql",
"entity": "LineItem",
"order_by": "invoice_id, id",
"fields": ["id", "invoice_id", "description", "quantity", "unit_price"]
},
{
"name": "payments",
"title": "Payments",
"slug": "payments",
"description": "All payments recorded against fixture invoices.",
"mode": "sql",
"entity": "Payment",
"order_by": "paid_at DESC",
"fields": ["id", "invoice_id", "amount", "method", "paid_at"]
}
]
}

View File

@@ -0,0 +1,124 @@
"""Datagen for the fixture-invoicing app.
Generates Customer / Invoice / LineItem / Payment records that match the
fixture's SQLAlchemy models. Obviously-fake names make it clear these are
not real business records.
"""
import random
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
try:
from station.tools.datagen.base import BaseDataGenerator
except ImportError:
class BaseDataGenerator:
pass
_COMPANIES = [
"Acme Widget Co.", "Fixture Industries", "Demo Corp.", "Placeholder LLC",
"Test Customer Holdings", "Lorem Enterprises", "Ipsum & Sons",
"Sample Partners", "Dummy Data Group", "Stub Systems",
]
_METHODS = ["cash", "card", "transfer"]
_STATUSES = ["draft", "sent", "paid", "void"]
class FixtureInvoicingGenerator(BaseDataGenerator):
"""Generates obviously-fake records for the fixture app."""
def customer(self, **kwargs) -> dict:
name = kwargs.pop("name", None) or random.choice(_COMPANIES)
slug = name.lower().replace(" ", "-").replace(".", "").replace(",", "")
return {
"id": str(uuid.uuid4()),
"name": name,
"email": f"billing+{slug}@example.invalid",
"created_at": datetime.utcnow().isoformat(),
**kwargs,
}
def invoice(self, customer_id: int | str | None = None, **kwargs) -> dict:
number = kwargs.pop("number", f"DEMO-{random.randint(1000, 9999)}")
issued = datetime.utcnow() - timedelta(days=random.randint(0, 60))
return {
"id": str(uuid.uuid4()),
"number": number,
"customer_id": customer_id or 1,
"issued_at": issued.isoformat(),
"due_at": (issued + timedelta(days=30)).isoformat(),
"status": kwargs.pop("status", random.choice(_STATUSES)),
**kwargs,
}
def line_item(self, invoice_id: int | str | None = None, **kwargs) -> dict:
qty = kwargs.pop("quantity", random.randint(1, 20))
price = kwargs.pop("unit_price", round(random.uniform(5, 500), 2))
return {
"id": str(uuid.uuid4()),
"invoice_id": invoice_id or 1,
"description": kwargs.pop(
"description", f"Widget {random.choice(['A', 'B', 'C'])} (fixture)"
),
"quantity": qty,
"unit_price": float(Decimal(str(price))),
**kwargs,
}
def payment(self, invoice_id: int | str | None = None, **kwargs) -> dict:
return {
"id": str(uuid.uuid4()),
"invoice_id": invoice_id or 1,
"amount": kwargs.pop("amount", round(random.uniform(50, 2000), 2)),
"method": kwargs.pop("method", random.choice(_METHODS)),
"paid_at": datetime.utcnow().isoformat(),
**kwargs,
}
def schema(self) -> dict:
return {
"models": {
"Customer": {
"doc": "A fixture customer — obviously-placeholder name.",
"fields": {
"id": {"type": "UUID", "pk": True},
"name": {"type": "str"},
"email": {"type": "str"},
"created_at": {"type": "datetime"},
},
},
"Invoice": {
"doc": "An invoice issued to a fixture customer.",
"fields": {
"id": {"type": "UUID", "pk": True},
"number": {"type": "str"},
"customer_id": {"type": "FK:Customer"},
"issued_at": {"type": "datetime"},
"due_at": {"type": "datetime", "nullable": True},
"status": {"type": "str"},
},
},
"LineItem": {
"doc": "A single billable line on an invoice.",
"fields": {
"id": {"type": "UUID", "pk": True},
"invoice_id": {"type": "FK:Invoice"},
"description": {"type": "str"},
"quantity": {"type": "int"},
"unit_price": {"type": "Decimal"},
},
},
"Payment": {
"doc": "A payment recorded against an invoice.",
"fields": {
"id": {"type": "UUID", "pk": True},
"invoice_id": {"type": "FK:Invoice"},
"amount": {"type": "Decimal"},
"method": {"type": "str"},
"paid_at": {"type": "datetime"},
},
},
}
}

View File

@@ -0,0 +1,44 @@
{
"models": {
"Customer": {
"doc": "A fixture customer (obviously-placeholder names).",
"fields": {
"id": {"type": "int", "pk": true},
"name": {"type": "str"},
"email": {"type": "str"},
"created_at": {"type": "datetime"}
}
},
"Invoice": {
"doc": "An invoice issued to a customer. Owns line items and payments.",
"fields": {
"id": {"type": "int", "pk": true},
"number": {"type": "str"},
"customer_id": {"type": "FK:Customer"},
"issued_at": {"type": "datetime"},
"due_at": {"type": "datetime", "nullable": true},
"status": {"type": "str"}
}
},
"LineItem": {
"doc": "A single billable line on an invoice.",
"fields": {
"id": {"type": "int", "pk": true},
"invoice_id": {"type": "FK:Invoice"},
"description": {"type": "str"},
"quantity": {"type": "int"},
"unit_price": {"type": "Decimal"}
}
},
"Payment": {
"doc": "A payment recorded against an invoice.",
"fields": {
"id": {"type": "int", "pk": true},
"invoice_id": {"type": "FK:Invoice"},
"amount": {"type": "Decimal"},
"method": {"type": "str"},
"paid_at": {"type": "datetime"}
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"environments": [
{
"name": "docker",
"url": "http://sample_backend:8000",
"auth_type": "none",
"default": true
},
{
"name": "local",
"url": "http://localhost:8120",
"auth_type": "none"
}
]
}

View File

@@ -0,0 +1,52 @@
"""Smoke tests for the fixture-invoicing API.
Runs via the soleprint tester. Target URL is read from environments.json
(local = http://sample_backend:8000 inside the soleprint docker network).
"""
from tests.base import ContractTestCase
class CustomersContractTest(ContractTestCase):
def test_list_customers_returns_list(self):
resp = self.get("/api/customers")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIsInstance(data, list)
# seed should have produced Acme + Test Customer
names = {c["name"] for c in data}
self.assertIn("Acme Widget Co.", names)
def test_create_and_delete_customer(self):
payload = {"name": "Ephemeral Corp.", "email": "e@example.invalid"}
created = self.post("/api/customers", json=payload)
self.assertEqual(created.status_code, 201)
customer_id = created.json()["id"]
fetched = self.get(f"/api/customers/{customer_id}")
self.assertEqual(fetched.status_code, 200)
self.assertEqual(fetched.json()["name"], "Ephemeral Corp.")
self.delete(f"/api/customers/{customer_id}")
missing = self.get(f"/api/customers/{customer_id}")
self.assertEqual(missing.status_code, 404)
class InvoicesContractTest(ContractTestCase):
def test_list_invoices_returns_seeded(self):
resp = self.get("/api/invoices")
self.assertEqual(resp.status_code, 200)
invoices = resp.json()
self.assertGreaterEqual(len(invoices), 3)
numbers = {i["number"] for i in invoices}
self.assertIn("DEMO-2026-001", numbers)
def test_invoice_detail_has_customer_and_lines(self):
invoices = self.get("/api/invoices").json()
first = invoices[0]
detail = self.get(f"/api/invoices/{first['id']}")
self.assertEqual(detail.status_code, 200)
body = detail.json()
self.assertIn("customer", body)
self.assertIn("line_items", body)
self.assertIn("payments", body)