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:
@@ -16,6 +16,7 @@ NETWORK_NAME=sample_network
|
||||
# PORTS (unique per room)
|
||||
# =============================================================================
|
||||
SOLEPRINT_PORT=12030
|
||||
NGINX_PORT=8130
|
||||
|
||||
# =============================================================================
|
||||
# GOOGLE OAUTH
|
||||
|
||||
44
cfg/sample/soleprint/atlas/books/fixture/index.md
Normal file
44
cfg/sample/soleprint/atlas/books/fixture/index.md
Normal 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 |
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
124
cfg/sample/soleprint/station/tools/datagen/fixture.py
Normal file
124
cfg/sample/soleprint/station/tools/datagen/fixture.py
Normal 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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
44
cfg/sample/soleprint/station/tools/graphgen/schema.json
Normal file
44
cfg/sample/soleprint/station/tools/graphgen/schema.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
cfg/sample/soleprint/station/tools/tester/environments.json
Normal file
15
cfg/sample/soleprint/station/tools/tester/environments.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user