Phase 7: serve the vet sample from the template tools and docs
- build.py: _TEMPLATE_COMPONENTS rewritten around the vet sample (53 kinds —
per-resource slices, jobs, worked appointment_reminder family, tests);
seed tokens dropped from the manifest ('sample: vet' instead)
- registry.py: get_app_component(kind) no longer renames item/example seeds;
prompts and tool docs updated to 'copy a slice and adapt'
- templates/README.md, 01_build_recipe, 40_ui_admin, wiki/03 updated off the
old seed scheme; repo inventory/config now exclude vet (docs move under
standards; react/vet itself is deleted in a later phase)
- regenerated MCP bundle, vendored templates, and site output
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""Unit tests for the appointment-reminder pipeline family."""
|
||||
from pathlib import Path
|
||||
|
||||
from pipelines.appointment_reminder.impl import (
|
||||
AppointmentReminderPipeline,
|
||||
ReminderComposer,
|
||||
get_appointment_reminder_pipeline,
|
||||
)
|
||||
from pipelines.shared.loader import build_pipeline, load_patterns
|
||||
|
||||
RESOURCES = Path(__file__).resolve().parents[2] / "pipelines" / "appointment_reminder" / "resources"
|
||||
|
||||
|
||||
class FakeDoc:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def model_dump(self, **kwargs):
|
||||
return self._data
|
||||
|
||||
|
||||
class FakeAppointmentSvc:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
async def read(self, id):
|
||||
return FakeDoc(self._data) if self._data else None
|
||||
|
||||
|
||||
def _appointment():
|
||||
return {
|
||||
"id": 1,
|
||||
"date": "2026-11-02T10:00:00",
|
||||
"pet": {"name": "Rex", "species": "dog", "parents": [{"name": "Kim", "email": "kim@example.com"}]},
|
||||
"vet": {"name": "Dr. Patel"},
|
||||
}
|
||||
|
||||
|
||||
def test_load_patterns_merges_fragments():
|
||||
registry = load_patterns(RESOURCES)
|
||||
assert registry["reminder"]["subject"] == "Appointment reminder"
|
||||
assert "{pet_name}" in registry["reminder"]["body"]
|
||||
|
||||
|
||||
def test_composer_uses_pattern_and_context():
|
||||
composer = ReminderComposer()
|
||||
composer.registry = load_patterns(RESOURCES)
|
||||
reminder = composer.compose(_appointment(), _appointment()["pet"], _appointment()["pet"]["parents"][0])
|
||||
assert reminder["to"] == "kim@example.com"
|
||||
assert reminder["subject"] == "Appointment reminder"
|
||||
assert "Rex" in reminder["body"] and "Kim" in reminder["body"]
|
||||
assert "2026-11-02" in reminder["body"]
|
||||
|
||||
|
||||
async def test_full_pipeline_delivers_reminder():
|
||||
pipeline = get_appointment_reminder_pipeline(FakeAppointmentSvc(_appointment()))
|
||||
assert isinstance(pipeline, AppointmentReminderPipeline)
|
||||
result = await pipeline.run(1)
|
||||
assert result["delivered"] == "log"
|
||||
assert result["to"] == "kim@example.com"
|
||||
|
||||
|
||||
async def test_pipeline_errors_on_missing_appointment():
|
||||
pipeline = get_appointment_reminder_pipeline(FakeAppointmentSvc(None))
|
||||
try:
|
||||
await pipeline.run(99)
|
||||
except ValueError as exc:
|
||||
assert "not found" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError for missing appointment")
|
||||
|
||||
|
||||
def test_build_pipeline_injects_registry():
|
||||
pipeline = build_pipeline(
|
||||
AppointmentReminderPipeline,
|
||||
resources_dir=RESOURCES,
|
||||
instances={"appointment_svc": FakeAppointmentSvc(_appointment())},
|
||||
)
|
||||
assert pipeline.compose.registry["reminder"]["subject"] == "Appointment reminder"
|
||||
@@ -1,22 +0,0 @@
|
||||
from domain.item.models import Item
|
||||
from domain.job.models import Job
|
||||
|
||||
|
||||
def test_item_defaults():
|
||||
item = Item(name="widget")
|
||||
assert item.quantity == 1
|
||||
|
||||
|
||||
def test_job_defaults():
|
||||
job = Job(name="sync")
|
||||
assert job.status == "pending"
|
||||
assert job.retry_count == 0
|
||||
assert job.error_message is None
|
||||
|
||||
|
||||
def test_job_rejects_unknown_status():
|
||||
try:
|
||||
Job(name="sync", status="exploded")
|
||||
except Exception:
|
||||
return
|
||||
raise AssertionError("status should be rejected")
|
||||
@@ -1,28 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pipelines.item_sync import run_item_sync
|
||||
|
||||
|
||||
class FakeItemSvc:
|
||||
def __init__(self):
|
||||
self.created = []
|
||||
|
||||
async def create(self, data: dict):
|
||||
self.created.append(dict(data))
|
||||
|
||||
|
||||
async def test_run_item_sync_creates_item():
|
||||
svc = FakeItemSvc()
|
||||
job = SimpleNamespace(id="j1", name="widget", payload={"quantity": 4})
|
||||
await run_item_sync(job, {"item_svc": svc})
|
||||
|
||||
assert len(svc.created) == 1
|
||||
assert svc.created[0] == {"name": "widget", "quantity": 4}
|
||||
|
||||
|
||||
async def test_run_item_sync_uses_payload_name():
|
||||
svc = FakeItemSvc()
|
||||
job = SimpleNamespace(id="j1", name="ignored", payload={"name": "gadget"})
|
||||
await run_item_sync(job, {"item_svc": svc})
|
||||
|
||||
assert svc.created[0]["name"] == "gadget"
|
||||
100
standards/mcp/templates/app/tests/unit/test_jobs_routes.py
Normal file
100
standards/mcp/templates/app/tests/unit/test_jobs_routes.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Unit tests for the jobs interface helpers and routes (memory backend)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from interfaces.jobs.routes import (
|
||||
create_job,
|
||||
delete_job,
|
||||
get_job,
|
||||
list_jobs,
|
||||
update_job,
|
||||
)
|
||||
|
||||
|
||||
class FakeDoc:
|
||||
"""Minimal document exposing the pydantic dump interface."""
|
||||
|
||||
def __init__(self, **fields):
|
||||
self.__dict__.update(fields)
|
||||
|
||||
def model_dump(self, **kwargs):
|
||||
return dict(self.__dict__)
|
||||
|
||||
|
||||
class FakeJobSvc:
|
||||
"""In-memory job service double."""
|
||||
|
||||
def __init__(self):
|
||||
self.store: dict[int, FakeDoc] = {}
|
||||
self._next = 1
|
||||
|
||||
async def search(self, filter=None):
|
||||
return list(self.store.values())
|
||||
|
||||
async def create(self, body):
|
||||
body.setdefault("status", "pending")
|
||||
body.setdefault("retry_count", 0)
|
||||
doc = FakeDoc(id=self._next, **body)
|
||||
self.store[self._next] = doc
|
||||
self._next += 1
|
||||
return doc
|
||||
|
||||
async def read(self, id):
|
||||
return self.store.get(id)
|
||||
|
||||
async def update(self, id, body):
|
||||
doc = FakeDoc(id=id, **body)
|
||||
self.store[id] = doc
|
||||
return doc
|
||||
|
||||
async def delete(self, id):
|
||||
return self.store.pop(id, None) is not None
|
||||
|
||||
|
||||
def _request(svc_obj):
|
||||
state = SimpleNamespace(db_resources={"job_svc": svc_obj})
|
||||
return SimpleNamespace(app=SimpleNamespace(state=state))
|
||||
|
||||
|
||||
async def test_create_list_get_update_delete_job():
|
||||
svc = FakeJobSvc()
|
||||
request = _request(svc)
|
||||
response = SimpleNamespace(status_code=200)
|
||||
|
||||
created = await create_job(
|
||||
request, {"name": "remind", "kind": "appointment_reminder"}, response
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert created["kind"] == "appointment_reminder"
|
||||
assert created["status"] == "pending"
|
||||
|
||||
page = await list_jobs(request)
|
||||
assert page["total"] == 1
|
||||
assert page["items"][0]["name"] == "remind"
|
||||
|
||||
one = await get_job(request, created["id"])
|
||||
assert one["id"] == created["id"]
|
||||
|
||||
updated = await update_job(
|
||||
request, created["id"], {"name": "remind-2", "kind": "appointment_reminder"}
|
||||
)
|
||||
assert updated["name"] == "remind-2"
|
||||
|
||||
response = SimpleNamespace(status_code=200)
|
||||
await delete_job(request, created["id"], response)
|
||||
assert response.status_code == 204
|
||||
assert (await list_jobs(request))["total"] == 0
|
||||
|
||||
|
||||
async def test_list_jobs_filters_by_status():
|
||||
svc = FakeJobSvc()
|
||||
request = _request(svc)
|
||||
await create_job(request, {"name": "a", "kind": "k"}, SimpleNamespace())
|
||||
await create_job(request, {"name": "b", "kind": "k", "status": "done"}, SimpleNamespace())
|
||||
|
||||
pending = await list_jobs(request, status="pending")
|
||||
assert pending["total"] == 1
|
||||
assert pending["items"][0]["name"] == "a"
|
||||
|
||||
done = await list_jobs(request, status="done")
|
||||
assert done["total"] == 1
|
||||
assert done["items"][0]["name"] == "b"
|
||||
121
standards/mcp/templates/app/tests/unit/test_worker.py
Normal file
121
standards/mcp/templates/app/tests/unit/test_worker.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Unit tests for the worker dispatch table and job status transitions."""
|
||||
from worker import HANDLERS, poll_jobs
|
||||
|
||||
|
||||
class FakeDoc:
|
||||
def __init__(self, **fields):
|
||||
self.__dict__.update(fields)
|
||||
|
||||
def model_dump(self, **kwargs):
|
||||
return dict(self.__dict__)
|
||||
|
||||
|
||||
class FakeJobSvc:
|
||||
def __init__(self, jobs):
|
||||
self.jobs = {j.id: j for j in jobs}
|
||||
self.patches: list[tuple[int, dict]] = []
|
||||
|
||||
async def search(self, filter=None):
|
||||
filter = filter or {}
|
||||
return [j for j in self.jobs.values() if all(j.__dict__.get(k) == v for k, v in filter.items())]
|
||||
|
||||
async def patch(self, id, body):
|
||||
self.patches.append((int(id), body))
|
||||
job = self.jobs[int(id)]
|
||||
for k, v in body.items():
|
||||
setattr(job, k, v)
|
||||
|
||||
|
||||
class FakeAppointmentSvc:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
async def read(self, id):
|
||||
return FakeDoc(**self._data) if self._data else None
|
||||
|
||||
|
||||
def _job(id=1, kind="appointment_reminder", payload=None):
|
||||
return FakeDoc(
|
||||
id=id,
|
||||
name="j",
|
||||
kind=kind,
|
||||
status="pending",
|
||||
payload=payload or {"appointment_id": 1},
|
||||
retry_count=0,
|
||||
)
|
||||
|
||||
|
||||
def test_dispatch_table_has_appointment_reminder():
|
||||
assert "appointment_reminder" in HANDLERS
|
||||
|
||||
|
||||
async def test_poll_jobs_runs_pending_and_marks_done(monkeypatch):
|
||||
monkeypatch.setattr("worker.settings.worker_poll_seconds", 0)
|
||||
appointment = {
|
||||
"id": 1,
|
||||
"date": "2026-11-02T10:00:00",
|
||||
"pet": {"name": "Rex", "parents": [{"name": "Kim", "email": "kim@example.com"}]},
|
||||
}
|
||||
job_svc = FakeJobSvc([_job()])
|
||||
resources = {"job_svc": job_svc, "appointment_svc": FakeAppointmentSvc(appointment)}
|
||||
|
||||
import asyncio
|
||||
|
||||
task = asyncio.create_task(poll_jobs(resources))
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.01)
|
||||
if job_svc.patches and job_svc.patches[-1][1].get("status") == "done":
|
||||
break
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
statuses = [body["status"] for _, body in job_svc.patches]
|
||||
assert statuses == ["processing", "done"]
|
||||
|
||||
|
||||
async def test_poll_jobs_marks_unknown_kind_failed(monkeypatch):
|
||||
monkeypatch.setattr("worker.settings.worker_poll_seconds", 0)
|
||||
job_svc = FakeJobSvc([_job(kind="nope")])
|
||||
resources = {"job_svc": job_svc, "appointment_svc": FakeAppointmentSvc(None)}
|
||||
|
||||
import asyncio
|
||||
|
||||
task = asyncio.create_task(poll_jobs(resources))
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.01)
|
||||
if job_svc.patches and job_svc.patches[-1][1].get("status") == "failed":
|
||||
break
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
last = job_svc.patches[-1][1]
|
||||
assert last["status"] == "failed"
|
||||
assert "no handler" in last["error_message"]
|
||||
assert last["retry_count"] == 1
|
||||
|
||||
|
||||
async def test_poll_jobs_skips_retries_exhausted(monkeypatch):
|
||||
monkeypatch.setattr("worker.settings.worker_poll_seconds", 0)
|
||||
monkeypatch.setattr("worker.settings.worker_max_retries", 1)
|
||||
job = _job()
|
||||
job.retry_count = 1
|
||||
job_svc = FakeJobSvc([job])
|
||||
resources = {"job_svc": job_svc, "appointment_svc": FakeAppointmentSvc(None)}
|
||||
|
||||
import asyncio
|
||||
|
||||
task = asyncio.create_task(poll_jobs(resources))
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert job_svc.patches == []
|
||||
Reference in New Issue
Block a user