update 33.1 50

This commit is contained in:
2026-08-10 03:23:30 -03:00
parent 33f0559268
commit ef63b02554
23 changed files with 753 additions and 368 deletions

View File

@@ -4,7 +4,22 @@
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./style.css": "./dist/style.css",
"./theme.css": "./src/theme.css",
"./tokens.css": "./src/tokens.css",
"./base.css": "./src/base.css",
"./dist/*": "./dist/*",
"./src/*": "./src/*"
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "vite build",
"build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "vue-tsc --noEmit"

View File

@@ -0,0 +1,8 @@
# pnpm 10+ moved settings here out of package.json.
#
# esbuild (vite's bundler) and vue-demi (pinia) need their postinstall to link
# platform binaries. Without this pnpm refuses to run them and exits non-zero,
# which makes every `pnpm typecheck` / `test` / `build` fail before it starts.
allowBuilds:
esbuild: true
vue-demi: true

View File

@@ -0,0 +1,69 @@
/* Framework base layer — element defaults written against tokens.css.
*
* This was duplicated byte-for-byte in every app's own styles.css (doocus-app,
* meetus-app). It is theme, not app, so it ships with the framework: an app that
* imports the framework gets a consistent shell without restating it.
*
* Retheme by replacing tokens.css — every value here resolves through it. */
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
height: 100%;
width: 100%;
}
body {
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-base);
}
button {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-2);
border: var(--panel-border);
border-radius: var(--panel-radius);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--surface-3);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select,
textarea {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-0);
border: var(--panel-border);
border-radius: 4px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}

View File

@@ -1,4 +1,10 @@
// Framework public API
// Theme (tokens + base layer). Imported here so the visual identity is part of
// the bundle rather than something each consumer must remember to wire up —
// every component below styles itself with the variables it defines.
import './theme.css'
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
export { SSEDataSource } from './datasources/SSEDataSource'
export { StaticDataSource } from './datasources/StaticDataSource'

View File

@@ -0,0 +1,11 @@
/* The whole visual identity in one import: design tokens + element defaults.
*
* index.ts imports this, so the theme travels with the bundle and cannot be
* forgotten — a dist that renders unthemed is a broken dist, not an unbranded
* one, because every component styles itself with var(--surface-0) and friends.
*
* Consumers of the built package import `soleprint-ui/style.css`.
* To retheme, override the variables from tokens.css after this import. */
@import './tokens.css';
@import './base.css';

View File

@@ -0,0 +1,37 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
/**
* Library build — emits dist/soleprint-ui.js plus a single dist/style.css
* containing the theme (tokens + base) and every component's scoped styles.
*
* The CSS is the point as much as the JS: components style themselves with
* var(--surface-0) and friends, so a bundle shipped without it renders broken.
*
* Peer packages are external so a consuming app resolves ONE copy of vue —
* two Vue instances break reactivity and provide/inject in ways that are
* miserable to debug.
*/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
formats: ['es'],
fileName: () => 'soleprint-ui.js',
cssFileName: 'style',
},
rollupOptions: {
external: ['vue', 'pinia', '@vue-flow/core', 'uplot'],
output: {
globals: { vue: 'Vue' },
},
},
},
})

View File

@@ -1,164 +1,87 @@
# Datagen - Test Data Generator
# Datagen Test Data Generator
Pluggable test data generators for various domain models and external APIs.
Room-specific test data generators, discovered and served by the hub.
## Purpose
- Generate realistic test data for Amar domain models
- Generate mock API responses for external services (MercadoPago, etc.)
- Can be plugged into any nest (test suites, mock veins, seeders)
- Domain-agnostic and reusable
The core ships the base class and the API only. **Generators themselves belong to a
room** (`cfg/<room>/soleprint/station/tools/datagen/`) and are merged into the built
instance — so no client's domain vocabulary lives here.
## Structure
```
datagen/
├── __init__.py
├── amar.py # Amar domain models (petowner, pet, cart, etc.)
├── mercadopago.py # MercadoPago API responses
── README.md # This file
├── base.py # BaseDataGenerator — the contract
├── api.py # FastAPI router, mounted at /tools/datagen
── templates/
│ └── index.html # browser UI
└── README.md # this file
```
## Usage
## Writing a generator
### In Tests
Subclass `BaseDataGenerator` and name each method after the model it generates. The
method name *is* the model name — there is no registry to update.
```python
from ward.tools.datagen.amar import AmarDataGenerator
from faker import Faker
def test_petowner_creation():
owner_data = AmarDataGenerator.petowner(address="Av. Corrientes 1234")
assert owner_data["address"] == "Av. Corrientes 1234"
```
# Guarded so the file also runs on its own, outside a built instance —
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
try:
from station.tools.datagen.base import BaseDataGenerator
except ImportError:
class BaseDataGenerator:
pass
### In Mock Veins
fake = Faker()
```python
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
@router.post("/v1/preferences")
async def create_preference(request: dict):
# Generate mock response
return MercadoPagoDataGenerator.preference(
description=request["items"][0]["title"],
total=request["items"][0]["unit_price"],
)
```
### In Seeders
class MyRoomGenerator(BaseDataGenerator):
def user(self, **kwargs):
return {"id": fake.uuid4(), "name": fake.name(), **kwargs}
```python
from ward.tools.datagen.amar import AmarDataGenerator
# Create 10 test pet owners
for i in range(10):
owner = AmarDataGenerator.petowner(is_guest=False)
# Save to database...
def product(self, category=None, **kwargs):
return {"id": fake.uuid4(), "name": fake.word(), "category": category, **kwargs}
```
## Design Principles
1. **Pluggable**: Can be used anywhere, not tied to specific frameworks
2. **Realistic**: Generated data matches real-world patterns
3. **Flexible**: Override any field via `**overrides` parameter
4. **Domain-focused**: Each generator focuses on a specific domain
5. **Stateless**: Pure functions, no global state
Drop it in `cfg/<room>/soleprint/station/tools/datagen/<name>.py` and rebuild the room.
## Generators
**Discovery rules** (`api.py:_load_generators`): every `*.py` in the tool directory is
scanned except `base.py`, `api.py`, and anything starting with `_`. The first class whose
name ends in `Generator` (and isn't `BaseDataGenerator`) is instantiated.
### AmarDataGenerator (amar.py)
## What the base class gives you
Generates data for Amar platform:
| Method | Purpose |
|---|---|
| `generate(model, count=1, **kwargs)` | Call the matching method `count` times; raises `ValueError` listing available models if there's no match |
| `available_models()` | Method names, minus the reserved ones — i.e. the models you support |
| `schema()` | Optional override returning a graphgen-compatible schema; `None` by default |
- `petowner()` - Pet owners (guest and registered)
- `pet()` - Pets with species, age, etc.
- `cart()` - Shopping carts
- `service_request()` - Service requests
- `filter_services()` - Service filtering by species/neighborhood
- `filter_categories()` - Category filtering
- `calculate_cart_summary()` - Cart totals with discounts
## HTTP API
### MercadoPagoDataGenerator (mercadopago.py)
Mounted at `/tools/datagen`:
Generates MercadoPago API responses:
| Route | Purpose |
|---|---|
| `GET /api/generators` | loaded generator files and their models |
| `GET /api/models` | models for one generator (`?generator=<name>`) |
| `POST /api/generate` | `{model, count, generator?, kwargs}` → generated items |
| `GET /api/schema` | graphgen-compatible schema, when the generator exposes one |
- `preference()` - Checkout Pro preference
- `payment()` - Payment (Checkout API/Bricks)
- `merchant_order()` - Merchant order
- `oauth_token()` - OAuth token exchange
- `webhook_notification()` - Webhook payloads
With one generator loaded, `generator` can be omitted everywhere — the only one is used.
## Examples
### Generate a complete turnero flow
```python
from ward.tools.datagen.amar import AmarDataGenerator
# Step 1: Guest pet owner
owner = AmarDataGenerator.petowner(
address="Av. Santa Fe 1234, Palermo",
is_guest=True
)
# Step 2: Pet
pet = AmarDataGenerator.pet(
owner_id=owner["id"],
name="Luna",
species="DOG",
age_value=3,
age_unit="years"
)
# Step 3: Cart
cart = AmarDataGenerator.cart(owner_id=owner["id"])
# Step 4: Add services to cart
services = AmarDataGenerator.filter_services(
species="DOG",
neighborhood_id=owner["neighborhood"]["id"]
)
cart_with_items = AmarDataGenerator.calculate_cart_summary(
cart,
items=[
{"service_id": services[0]["id"], "price": services[0]["price"], "quantity": 1, "pet_id": pet["id"]},
]
)
# Step 5: Service request
request = AmarDataGenerator.service_request(cart_id=cart["id"])
```
### Generate a payment flow
```python
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
# Create preference
pref = MercadoPagoDataGenerator.preference(
description="Visita a domicilio",
total=95000,
external_reference="SR-12345"
)
# Simulate payment
payment = MercadoPagoDataGenerator.payment(
transaction_amount=95000,
description="Visita a domicilio",
status="approved",
application_fee=45000 # Platform fee (split payment)
)
# Webhook notification
webhook = MercadoPagoDataGenerator.webhook_notification(
topic="payment",
resource_id=str(payment["id"])
)
```bash
curl -X POST localhost:12000/tools/datagen/api/generate \
-H 'content-type: application/json' \
-d '{"model": "user", "count": 3}'
```
## Future Generators
## Design principles
- `google.py` - Google API responses (Calendar, Sheets)
- `whatsapp.py` - WhatsApp API responses
- `slack.py` - Slack API responses
1. **Room-owned** — domain vocabulary lives in `cfg/<room>/`, never in core.
2. **Convention over registration** — a method name is a model name.
3. **Flexible** — any field is overridable through `**kwargs`.
4. **Stateless** — no global state between calls.
5. **Standalone** — usable directly as a Python class, with or without the hub.

View File

@@ -1,178 +1,127 @@
# Tester - HTTP Contract Test Runner
# Tester HTTP Contract Test Runner
Web UI for discovering and running contract tests.
Discovers and runs contract tests against any environment, with a web UI for
visibility.
**Test Definitions****Tester (Runner + UI)****Target API**
## Quick Start
```bash
# Sync tests from production repo (local dev)
/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh
# Run locally
cd /home/mariano/wdir/ama/pawprint/ward
python -m tools.tester
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
python -m tester discover # list what was found
# Open in browser
http://localhost:12003/tester
# In a built instance, the UI is mounted by the hub:
# http://localhost:12000/tools/tester
```
## Architecture
## Where tests live
**Test Definitions****Tester (Runner + UI)****Target API**
**No test bodies are committed to core.** The tool ships the base class, the
runner and the UI. Tests belong to a room:
```
amar_django_back_contracts/
└── tests/contracts/ ← Test definitions (source of truth)
├── mascotas/
├── productos/
└── workflows/
ward/tools/tester/
├── tests/ ← Synced from contracts (deployment)
│ ├── mascotas/
│ ├── productos/
│ └── workflows/
├── base.py ← HTTP test base class
├── core.py ← Test discovery & execution
├── api.py ← FastAPI endpoints
└── templates/ ← Web UI
cfg/<room>/soleprint/station/tools/tester/tests/
```
## Strategy: Separation of Concerns
They are merged into the built instance and discovered from `tests/` there.
See [`tests/test_template.py`](tests/test_template.py) — an intentionally empty
test file whose docstring covers the execution modes, environment targeting, the
`ContractTestCase` surface, and a worked example.
1. **Tests live in production repo** (`amar_django_back_contracts`)
- Developers write tests alongside code
- Tests are versioned with the API
- PR reviews include test changes
Keeping tests in the room rather than the runner means they version alongside the
API they describe, and the runner stays reusable across projects.
2. **Tester consumes tests** (`ward/tools/tester`)
- Provides web UI for visibility
- Runs tests against any target (dev, stage, prod)
- Shows test coverage to product team
## Layout
3. **Deployment syncs tests**
- `sync-tests.sh` copies tests from contracts to tester
- Deployment script includes test sync
- Server always has latest tests
```
tester/
├── base.py # ContractTestCase — httpx + stdlib unittest
├── core.py # discovery & execution
├── cli.py # python -m tester [discover|run]
├── config.py # .env + environment overrides
├── api.py # FastAPI routes
├── environments.json # named targets
├── templates/ # web UI
├── gherkin/ # optional feature/scenario metadata mapping
├── playwright/ # browser adapter (scaffolded; see the template)
└── tests/
├── base.py
├── test_template.py # start here
└── example/ # fallback health check, runs with no room config
```
## Configuration
### Single Environment (.env)
### Single environment (.env)
```env
CONTRACT_TEST_URL=https://demo.amarmascotas.ar
CONTRACT_TEST_URL=https://api.example.com
CONTRACT_TEST_API_KEY=your-api-key-here
```
### Multiple Environments (environments.json)
### Multiple environments (environments.json)
Configure multiple target environments with individual tokens:
Same suite, many targets — this is the point of the tool.
```json
[
{
"id": "demo",
"name": "Demo",
"url": "https://demo.amarmascotas.ar",
"id": "local",
"name": "Local",
"url": "http://localhost:8000",
"api_key": "",
"description": "Demo environment for testing",
"description": "Local development server",
"default": true
},
{
"id": "dev",
"name": "Development",
"url": "https://dev.amarmascotas.ar",
"api_key": "dev-token-here",
"description": "Development environment"
},
{
"id": "prod",
"name": "Production",
"url": "https://amarmascotas.ar",
"api_key": "prod-token-here",
"description": "Production (use with caution!)"
"id": "stage",
"name": "Staging",
"url": "https://stage.example.com",
"api_key": "stage-token-here",
"description": "Staging environment"
}
]
```
**Environment Selector**: Available in UI header on both Runner and Filters pages. Selection persists via localStorage.
## Web UI Features
Selection is available in the UI header and persists via localStorage. Tokens are
per-environment; keep real ones in a room's gitignored config, never here.
- **Filters**: Advanced filtering by domain, module, status, and search
- **Runner**: Execute tests with real-time progress tracking
- **Multi-Environment**: Switch between dev/stage/prod with per-environment tokens
- **URL State**: Filter state persists via URL when running tests
- **Real-time Status**: See test results as they run
See the template for every `CONTRACT_TEST_*` variable.
## API Endpoints
## API
```
GET /tools/tester/ # Runner UI
GET /tools/tester/filters # Filters UI
GET /tools/tester/api/tests # List all tests
GET /tools/tester/api/tests/tree # Tests grouped as a tree
GET /tools/tester/api/environments # List environments
POST /tools/tester/api/environment/select # Switch environment
POST /tools/tester/api/run # Start test run
GET /tools/tester/api/run/{run_id} # Get run status (polling)
GET /tools/tester/api/run/{run_id} # Run status (polling)
GET /tools/tester/api/runs # List all runs
GET /tools/tester/api/features # Gherkin features
POST /tools/tester/api/features/sync # Sync feature files
```
## Usage Flow
### From Filters to Runner
### URL parameters
1. Go to `/tools/tester/filters`
2. Filter tests (domain, module, search)
3. Select tests to run
4. Click "Run Selected"
5. → Redirects to Runner with filters applied and auto-starts execution
The runner accepts deep links:
### URL Parameters
Runner accepts URL params for deep linking:
```
/tools/tester/?run=abc123&domains=mascotas&search=owner
```
- `run` - Auto-load results for this run ID
- `domains` - Filter by domains (comma-separated)
- `modules` - Filter by modules (comma-separated)
- `search` - Search term for test names
- `status` - Filter by status (passed,failed,skipped)
## Deployment
Tests are synced during deployment:
```bash
# Full deployment (includes test sync)
cd /home/mariano/wdir/ama/pawprint/deploy
./deploy.sh
# Or sync tests only
/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh
/tools/tester/?run=abc123&modules=customers&search=invoice
```
## Why This Design?
**Problem**: Tests scattered, no visibility, hard to demonstrate value
**Solution**:
- Tests in production repo (developer workflow)
- Tester provides visibility (product team, demos)
- Separation allows independent evolution
**Benefits**:
- Product team sees test coverage
- Demos show "quality dashboard"
- Tests protect marketplace automation work
- Non-devs can run tests via UI
- `run` — auto-load results for this run ID
- `domains` / `modules` — comma-separated filters
- `search` — search term for test names
- `status``passed,failed,skipped`
## Related
## Why this design
- Production tests: `/home/mariano/wdir/ama/amar_django_back_contracts/tests/contracts/`
- Sync script: `/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh`
- Ward system: `/home/mariano/wdir/ama/pawprint/ward/`
Tests scattered across repos give no visibility and are hard to demonstrate.
Keeping definitions with the API while the runner stays generic means the suite
evolves with the code, non-developers can run it from the UI, and the same tests
prove the contract in every environment you can point them at.

View File

@@ -1,10 +1,10 @@
"""
CLI entry point for contracts_http tool.
CLI entry point for the tester tool.
Usage:
python -m contracts_http discover
python -m contracts_http run
python -m contracts_http run mascotas
python -m tester discover
python -m tester run
python -m tester run customers # only tests matching a pattern
"""
from .cli import main

View File

@@ -113,7 +113,7 @@ def main(args=None):
# run command
run_parser = subparsers.add_parser("run", help="Run tests")
run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g., 'mascotas', 'pet_owners')")
run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g. 'customers', 'invoices')")
args = parser.parse_args(args)

View File

@@ -4,22 +4,22 @@ Map tests to Gherkin scenarios based on metadata.
Tests can declare their Gherkin metadata via docstrings:
```python
def test_coverage_check(self):
def test_create_then_read_back(self):
'''
Feature: Reservar turno veterinario
Scenario: Verificar cobertura en zona disponible
Tags: @smoke @coverage
Feature: Customer records
Scenario: A created customer can be read back
Tags: @smoke @customers
'''
```
Or via class docstrings:
```python
class TestCoverageFlow(ContractHTTPTestCase):
"""
Feature: Reservar turno veterinario
Tags: @coverage
"""
class TestCustomers(ContractTestCase):
'''
Feature: Customer records
Tags: @customers
'''
```
"""

View File

@@ -12,11 +12,16 @@ def sync_features_from_album(
tester_path: Optional[Path] = None
) -> dict:
"""
Sync .feature files from album/book/gherkin-samples/ to ward/tools/tester/features/.
Sync .feature files from an atlas book into the tester's features/ directory.
Feature files are room-owned and live in a book:
cfg/<room>/soleprint/atlas/books/gherkin-samples/
which lands at atlas/books/gherkin-samples/ in a built instance. They are
synced, not committed here (see features/.gitignore).
Args:
album_path: Path to album/book/gherkin-samples/ (auto-detected if None)
tester_path: Path to ward/tools/tester/features/ (auto-detected if None)
album_path: Path to the gherkin-samples book (auto-detected if None)
tester_path: Path to tester/features/ (auto-detected if None)
Returns:
Dict with sync stats: {synced: int, skipped: int, errors: int}
@@ -26,9 +31,9 @@ def sync_features_from_album(
tester_path = Path(__file__).parent.parent / "features"
if album_path is None:
# Attempt to find album in pawprint
pawprint_root = Path(__file__).parent.parent.parent.parent
album_path = pawprint_root / "album" / "book" / "gherkin-samples"
# parents[4] is the instance root — station/tools/tester/gherkin/sync.py
instance_root = Path(__file__).resolve().parents[4]
album_path = instance_root / "atlas" / "books" / "gherkin-samples"
# Ensure paths exist
if not album_path.exists():

View File

@@ -1,73 +1,42 @@
# Contract Tests
API contract tests organized by Django app, with optional workflow tests.
Black-box HTTP tests that validate an API contract. Framework-agnostic by
construction: they talk to a URL, so they run against any implementation and any
environment.
## Testing Modes
**No tests are committed here.** This directory holds the base class and a
template; test bodies belong to a room:
Two modes via `CONTRACT_TEST_MODE` environment variable:
| Mode | Command | Description |
|------|---------|-------------|
| **api** (default) | `pytest tests/contracts/` | Fast, Django test client, test DB |
| **live** | `CONTRACT_TEST_MODE=live pytest tests/contracts/` | Real HTTP, LiveServerTestCase, test DB |
```
cfg/<room>/soleprint/station/tools/tester/tests/
```
### Mode Comparison
They are merged into the built instance and discovered from there.
| | `api` (default) | `live` |
|---|---|---|
| **Base class** | `APITestCase` | `LiveServerTestCase` |
| **HTTP** | In-process (Django test client) | Real HTTP via `requests` |
| **Auth** | `force_authenticate()` | JWT tokens via API |
| **Database** | Django test DB (isolated) | Django test DB (isolated) |
| **Speed** | ~3-5 sec | ~15-30 sec |
| **Server** | None (in-process) | Auto-started by Django |
## Start here
### Key Point: Both Modes Use Test Database
[`test_template.py`](test_template.py) — an intentionally empty test file whose
docstring carries the whole story: the execution modes, environment targeting, the
`ContractTestCase` surface, and a worked example.
Neither mode touches your real database. Django automatically:
1. Creates a test database (prefixed with `test_`)
2. Runs migrations
3. Destroys it after tests complete
## The two rules
## File Structure
1. **One suite, any environment.** A test states what the API promises, never who
implements it or where it runs. Point it at a laptop container, a cluster
namespace, or a deployed host — the result should only change if the contract
broke.
```
tests/contracts/
├── base.py # Mode switcher (imports from base_api or base_live)
├── base_api.py # APITestCase implementation
├── base_live.py # LiveServerTestCase implementation
├── conftest.py # pytest-django configuration
├── endpoints.py # API paths (single source of truth)
├── helpers.py # Shared test data helpers
├── mascotas/ # Django app: mascotas
│ ├── test_pet_owners.py
│ ├── test_pets.py
│ └── test_coverage.py
├── productos/ # Django app: productos
│ ├── test_services.py
│ └── test_cart.py
├── solicitudes/ # Django app: solicitudes
│ └── test_service_requests.py
└── workflows/ # Multi-step API sequences (e.g., turnero booking flow)
└── test_turnero_general.py
```
2. **No helper framework tools.** stdlib `unittest` and `httpx`. No pytest
fixtures or plugins, no framework test client, no factories or DSL, no ORM or
database access. A test that needs framework helpers to express itself has
stopped testing the contract, and stopped being portable.
## Running Tests
## Running
```bash
# All contract tests
pytest tests/contracts/
# Single app
pytest tests/contracts/mascotas/
# Single file
pytest tests/contracts/mascotas/test_pet_owners.py
# Live mode (real HTTP)
CONTRACT_TEST_MODE=live pytest tests/contracts/
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
python -m tester discover # list what was found
```
Targets are configured in [`../environments.json`](../environments.json) and
selectable from the web UI. See the template for every environment variable.

View File

@@ -0,0 +1,143 @@
"""
TEMPLATE — how to write a contract test. Intentionally empty: no test runs from
this file, and no tests are committed to the core repo at all.
Tests belong to a room:
cfg/<room>/soleprint/station/tools/tester/tests/
They get merged into the built instance and discovered from there. Core ships the
base class, the runner and the UI — never anyone's test bodies.
THE POINT
─────────
One suite, any environment. A test says what the API promises; it never says who
implements it or where it runs. Point the same file at a container on your laptop,
a namespace in the cluster, or a deployed host, and the answer should only differ
if the contract actually broke.
That constrains how tests are written, which is the second idea:
NO HELPER FRAMEWORK TOOLS
─────────────────────────
Deliberately plain. A test makes an HTTP call and asserts on the response.
- stdlib `unittest`, not pytest fixtures/plugins/parametrize machinery
- `httpx` against a URL, not a framework test client
- no factories, no DSL, no ORM access, no database setup
- no in-process shortcuts — if the test can reach it, so can a client
The moment a test needs framework helpers to express itself, it has stopped
testing the contract and started testing the implementation. It also stops being
portable: helper-bound tests only run where that framework runs.
(Django, where it appears at all, is an optional private DB editor via its admin —
never the framework, and never something a test reaches into.)
MODES OF EXECUTION
──────────────────
A mode is how a test reaches the system under test. Only the first is built.
http direct BUILT, and the one that matters. `ContractTestCase` below: pure
httpx over the wire. Discovered by
`unittest.TestLoader().discover(pattern="test_*.py")`, so a test
MUST subclass `ContractTestCase` to appear in the runner.
browser SCAFFOLDED, not wired end to end, and rarely the right tool.
`playwright/runner.py` shells out (`npx playwright test
--reporter=json`) and parses the report.
Browser tests earn their cost on complex UIs. Dashboards, monitors
and log views are not that — they render what an endpoint already
returned, so testing the endpoint tests the dashboard. Reach for
this only when behaviour lives in the browser and nowhere else;
data visualisation may eventually qualify. Not now.
any language INTENDED, not built. The browser adapter is the shape it would take:
a runner owes only a command to invoke and a machine-readable report
to parse. Nothing about that requires the test to be Python — R, Go,
k6 or a shell script satisfy the same contract.
Environment targeting is orthogonal to mode. `environments.json` holds the targets:
[{"id": "local", "name": "Local", "url": "http://localhost:8000",
"api_key": "", "description": "...", "default": true}]
Select one via `POST /tools/tester/api/environment/select`, or set the env directly:
CONTRACT_TEST_URL required — base URL of the target
CONTRACT_TEST_AUTH_TYPE bearer (default) | api-key | none
CONTRACT_TEST_TOKEN bearer token; fetched from the token endpoint if unset
CONTRACT_TEST_API_KEY required when auth type is api-key
CONTRACT_TEST_TOKEN_ENDPOINT default /api/token/
CONTRACT_TEST_USER / CONTRACT_TEST_PASSWORD used to fetch a token
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
WRITING ONE
───────────
The domain below is the in-repo invoicing fixture (cfg/sample) — deliberately
generic. Substitute your own; the shape is the part that transfers.
from ..base import ContractTestCase
class TestCustomers(ContractTestCase):
'''Customer endpoints.'''
def test_list_returns_customers(self):
'''A list endpoint returns a list.'''
response = self.get("/api/customers/")
self.assert_status(response, 200)
self.assert_is_list(response.data)
def test_create_then_read_back(self):
'''What was written is what comes back.'''
created = self.post("/api/customers/", {"name": "Acme Ltd"})
self.assert_status(created, 201)
self.assert_has_fields(created.data, "id", "name")
fetched = self.get(f"/api/customers/{created.data['id']}/")
self.assert_status(fetched, 200)
self.assertEqual(fetched.data["name"], "Acme Ltd")
def test_unknown_customer_is_404(self):
'''Absence is reported, not guessed at.'''
self.assert_status(self.get("/api/customers/00000000/"), 404)
Inherited from `ContractTestCase` (see ../base.py) — this is the whole surface:
self.get / post / put / patch / delete auth headers applied, .data parsed
self.assert_status(response, code)
self.assert_has_fields(data, *names)
self.assert_is_list(data, min_length=0)
self.base_url / self.token / self.api_key
Conventions that keep a suite portable:
- Group by area in subfolders; put multi-step sequences under `workflows/`.
- Keep paths in one `endpoints.py` per room so a URL change is a one-line diff.
- Create what you need through the API and assert on what comes back. A test that
depends on data already being there only passes in the environment it was
written against, which defeats the point.
- Skip, don't fail, when the target is simply unreachable — an unreachable
environment is not a broken contract.
GHERKIN (OPTIONAL)
──────────────────
A test can declare feature/scenario metadata in its docstring; the mapper reads it
to tie runs back to `.feature` files. Feature files are synced, not committed
(see features/.gitignore).
def test_create_then_read_back(self):
'''
Feature: Customer records
Scenario: A created customer can be read back
Tags: @smoke @customers
'''
"""