diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 8e3a9f0..97282f3 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -102,6 +102,41 @@ async def list_printers(session: SessionDep) -> list[PrinterRead]: return result +# --------------------------------------------------------------------------- +# GET /api/printers/{id} +# --------------------------------------------------------------------------- + + +@router.get( + "/{printer_id}", + response_model=PrinterRead, + summary="Get printer detail", + description=( + "Returns full metadata for a single printer, including the ``paused`` " + "flag joined from ``printer_state``. Returns 404 when the printer is " + "not registered." + ), +) +async def get_printer( + printer_id: UUID, + session: SessionDep, +) -> PrinterRead: + """Return full printer metadata for a single printer.""" + printer = await _get_printer_or_404(session, printer_id) + state = await printer_state_repo.get(session, printer_id) + return PrinterRead( + id=printer.id, + name=printer.name, + model=printer.model, + backend=printer.backend, + connection=dict(printer.connection), + enabled=printer.enabled, + paused=state.paused if state is not None else False, + created_at=printer.created_at, + updated_at=printer.updated_at, + ) + + # --------------------------------------------------------------------------- # GET /api/printers/{id}/status # --------------------------------------------------------------------------- diff --git a/backend/app/api/routes/templates.py b/backend/app/api/routes/templates.py index 0abf431..462a64a 100644 --- a/backend/app/api/routes/templates.py +++ b/backend/app/api/routes/templates.py @@ -1,11 +1,15 @@ -"""REST endpoint for the Templates aggregate (Phase 6a Task 2). - -Single read-only endpoint — template CRUD is out of scope for Phase 6a. +"""REST endpoints for the Templates aggregate (Phase 6a Task 2 + Bug-3 fix). Routes ------ -GET /api/templates?app= — list all templates, optionally filtered - by integration app (snipeit, grocy, spoolman, …) +GET /api/templates?app= — list all templates, optionally + filtered by integration app (snipeit, grocy, spoolman, …) +POST /api/render/preview?key= — render a sample label as PNG + +The preview endpoint is used by the frontend template-detail page to show a +rendered preview image. Sample values are sourced from the template's own +``preview_sample`` block in its definition — the route does NOT fabricate +sample data. Templates without ``preview_sample`` return HTTP 422. References: docs/superpowers/specs/2026-05-16-phase6a-rest-api-design.md — Templates section @@ -14,21 +18,203 @@ from __future__ import annotations -from typing import Annotated +import asyncio +import io +import logging +from typing import Annotated, Any -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from app.db.session import get_session from app.repositories import templates as templates_repo +from app.schemas.label_data import LabelData +from app.schemas.template import TemplateSchema from app.schemas.template_read import TemplateRead +from app.services.label_renderer import LabelRenderer + +_log = logging.getLogger(__name__) router = APIRouter(prefix="/api/templates", tags=["templates"]) +# Separate router for /api/render so the preview endpoint can live here while +# the prefix keeps it at /api/render/preview (not /api/templates/render/preview). +render_router = APIRouter(prefix="/api/render", tags=["templates"]) + # Type alias for the session dependency SessionDep = Annotated[AsyncSession, Depends(get_session)] +def _build_label_data( + template_key: str, + template_app: str | None, + preview_sample: dict[str, Any], +) -> LabelData: + """Build a LabelData from a template's preview_sample dict. + + The template is responsible for declaring values for every ``field`` + and ``data_field`` its elements reference. Missing values raise + HTTPException 422. + """ + try: + # source_app is filled from the template's own ``app`` field — falls + # back to "generic" for templates without an integration. + return LabelData( + primary_id=str(preview_sample.get("primary_id", "")), + title=str(preview_sample.get("title", "")), + qr_payload=str(preview_sample.get("qr_payload", "")), + source_app=template_app or "generic", + secondary=tuple(preview_sample.get("secondary", ()) or ()), + ) + except Exception as exc: # ValidationError or coercion error + raise HTTPException( + status_code=422, + detail=(f"Template {template_key!r} has an invalid preview_sample block: {exc}"), + ) from exc + + +# LabelData fields that preview_sample values map to (via _build_label_data). +# Elements reference these by name via `field` / `data_field` on LayoutElement. +_LABEL_DATA_FIELDS: frozenset[str] = frozenset({"primary_id", "title", "qr_payload", "secondary"}) + + +def _validate_preview_sample_fields( + template_key: str, + elements: tuple[Any, ...], + preview_sample: dict[str, Any], +) -> None: + """Raise HTTP 422 if preview_sample is missing any field required by elements. + + Each LayoutElement of type ``text`` references a ``field`` on LabelData; + type ``qr`` references a ``data_field``. Both must be present in + ``preview_sample`` so the renderer does not silently produce empty output. + """ + element_fields = set() + for el in elements: + if el.type == "text" and el.field: + element_fields.add(el.field) + elif el.type == "qr" and el.data_field: + element_fields.add(el.data_field) + + # Restrict check to known LabelData fields — unknown names will resolve to + # empty strings via _resolve_field (getattr fallback) which is acceptable. + missing = (element_fields & _LABEL_DATA_FIELDS) - set(preview_sample.keys()) + if missing: + raise HTTPException( + status_code=422, + detail=( + f"Template {template_key!r} preview_sample is missing fields " + f"required by its elements: {sorted(missing)}. " + "Add these keys to the template's 'preview_sample' block." + ), + ) + + +@render_router.post( + "/preview", + response_class=Response, + responses={ + 200: { + "content": {"image/png": {"schema": {"type": "string", "format": "binary"}}}, + "description": "PNG image of the rendered sample label", + } + }, + summary="Render a template preview as PNG", + description=( + "Renders the named template with the sample values declared in the " + "template's own ``preview_sample`` block and returns a PNG image. " + "Returns 404 if the template key is not registered. " + "Returns 422 if the template has no ``preview_sample`` block." + ), +) +async def render_preview( + request: Request, + session: SessionDep, + key: str = Query(description="Template key, e.g. 'snipeit-12mm'"), +) -> Response: + """Render a sample preview PNG for the given template key. + + Sample values are taken from the template's own ``preview_sample`` block + (in ``template.definition``). Templates that do not declare one return + HTTP 422 with a clear error message — the route does NOT fabricate + fallback sample data. + + The LabelRenderer is reused from ``app.state.label_renderer`` (wired by + the lifespan) to avoid per-request font-loading overhead. The CPU-bound + render + PNG encode is offloaded to ``asyncio.to_thread`` so it does not + block the event loop. + """ + template_row = await templates_repo.get_by_key(session, key) + if template_row is None: + raise HTTPException(status_code=404, detail=f"template {key!r} not found") + + definition = dict(template_row.definition) + + # The preview_sample block lives in the template definition. Without it + # the template cannot be previewed — we refuse to guess on its behalf. + preview_sample = definition.get("preview_sample") + if not preview_sample or not isinstance(preview_sample, dict): + raise HTTPException( + status_code=422, + detail=( + f"Template {template_row.key!r} has no preview_sample in its " + "definition. Add a 'preview_sample' block to the template YAML " + "to enable previews." + ), + ) + + # Reconstruct TemplateSchema from the DB row — the definition column stores + # the TemplateSchema field values. Supplement missing fields from the row's + # top-level columns (id→key, tape_mm→tape_width_mm, etc.) so that rows + # created before the definition was normalised can still render. + schema_dict = dict(definition) + schema_dict.setdefault("id", template_row.key) + schema_dict.setdefault("name", template_row.name) + schema_dict.setdefault("app", template_row.app) + schema_dict.setdefault("tape_mm", template_row.tape_width_mm) + schema_dict.setdefault("schema_version", template_row.schema_version) + schema_dict.setdefault("elements", []) + + try: + template_schema = TemplateSchema(**schema_dict) + except Exception as exc: + # Log the sanitised key from the DB row (trusted), NOT the raw query + # parameter, to prevent log injection via crafted key values. + _log.warning("render_preview: invalid definition for key=%r: %s", template_row.key, exc) + raise HTTPException(status_code=422, detail=f"invalid template definition: {exc}") from exc + + # Validate that preview_sample provides all fields referenced by elements. + _validate_preview_sample_fields(template_row.key, template_schema.elements, preview_sample) + + sample_data = _build_label_data(template_row.key, template_row.app, preview_sample) + + # Reuse the shared renderer from app.state (avoids per-request font-loading). + # Fall back to a fresh instance when running outside a full lifespan + # (e.g. unit tests that don't wire app.state). + renderer: LabelRenderer = getattr(request.app.state, "label_renderer", None) or LabelRenderer() + + def _render_and_encode() -> bytes: + """CPU-bound render + PNG encode — runs in a thread pool.""" + try: + img = renderer.render(template_schema, sample_data) + except ValueError as exc: + # Log the sanitised key from the DB row (trusted), NOT the raw query + # parameter, to prevent log injection via crafted key values. + _log.warning("render_preview: render failed for key=%r: %s", template_row.key, exc) + raise + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + try: + png_bytes = await asyncio.to_thread(_render_and_encode) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + return Response(content=png_bytes, media_type="image/png") + + @router.get( "", response_model=list[TemplateRead], diff --git a/backend/app/main.py b/backend/app/main.py index 007f6b6..0a6c1bd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -325,9 +325,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.printer_id = printer.id app.state.printer_host = discovery_host app.state.printer_snmp_community = settings.printer_snmp_community + # Shared LabelRenderer reused by both PrintService and the preview endpoint. + # Constructing it once avoids repeated font-loading overhead on every + # POST /api/render/preview request. + shared_renderer = LabelRenderer() + app.state.label_renderer = shared_renderer app.state.print_service = PrintService( template_loader=TemplateLoader, - renderer=LabelRenderer(), + renderer=shared_renderer, print_queue=queue, lookup_service=AppLookupService(), printer_id=printer.id, @@ -589,6 +594,7 @@ async def readiness( app.include_router(events_routes.router) app.include_router(printers_routes.router) app.include_router(templates_routes.router) + app.include_router(templates_routes.render_router) app.include_router(jobs_routes.router) app.include_router(lookup_routes.router) app.include_router(webhooks_routes.router) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 8cf5785..01d6227 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -32,7 +32,7 @@ class PrinterRead(BaseModel): backend: str connection: dict[str, object] enabled: bool - paused: bool = False # joined from printer_state + paused: bool # joined from printer_state — always set explicitly by callers created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py index ce69be6..34d280d 100644 --- a/backend/app/schemas/template.py +++ b/backend/app/schemas/template.py @@ -6,6 +6,11 @@ Templates are frozen at construction so they can be safely seeded as module-level constants (see app/seed/templates.py in PR D2). + +Immutability note: Pydantic `frozen=True` prevents attribute re-assignment +but does NOT deep-freeze container values. The ``preview_sample`` field +therefore uses ``tuple[str, ...]`` for its sequence type (instead of +``list[str]``) so the entire schema is truly immutable after construction. """ from __future__ import annotations @@ -61,6 +66,14 @@ class TemplateSchema(BaseModel): validated at load time against ``IntegrationRegistry``; the schema itself accepts any string so plugins can be added without a schema migration. + + ``preview_sample`` is an optional mapping of field name → sample value + used by the preview-render endpoint (``POST /api/render/preview``). + Each template declares its own preview values so the route never has + to fabricate sample data per-app. Keys must match the ``field`` / + ``data_field`` names referenced by ``elements``; supported keys are + ``primary_id``, ``title``, ``qr_payload``, and optionally ``secondary`` + (list/tuple of additional lines). """ model_config = ConfigDict(frozen=True) @@ -71,3 +84,7 @@ class TemplateSchema(BaseModel): app: str | None tape_mm: int elements: tuple[LayoutElement, ...] + # Values use tuple (not list) so the entire schema is deeply immutable — + # Pydantic frozen=True only prevents attribute re-assignment, not mutation + # of mutable containers stored in those attributes. + preview_sample: dict[str, str | int | float | bool | tuple[str, ...]] | None = None diff --git a/backend/app/seed/templates/grocy-12mm.yaml b/backend/app/seed/templates/grocy-12mm.yaml index 09ff3a5..ea2554c 100644 --- a/backend/app/seed/templates/grocy-12mm.yaml +++ b/backend/app/seed/templates/grocy-12mm.yaml @@ -9,3 +9,7 @@ elements: - { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload } - { type: text, x: 100, y: 18, field: primary_id, font_size: 22 } - { type: text, x: 100, y: 60, field: title, font_size: 14 } +preview_sample: + primary_id: "Erdbeermarmelade" + title: "Lager > Vorrat" + qr_payload: "https://grocy.example.com/stock/products/47" diff --git a/backend/app/seed/templates/grocy-18mm.yaml b/backend/app/seed/templates/grocy-18mm.yaml index c74f05c..50115c5 100644 --- a/backend/app/seed/templates/grocy-18mm.yaml +++ b/backend/app/seed/templates/grocy-18mm.yaml @@ -9,3 +9,8 @@ elements: - { type: text, x: 170, y: 20, field: primary_id, font_size: 32 } - { type: text, x: 170, y: 70, field: title, font_size: 20 } - { type: text, x: 170, y: 110, field: secondary, font_size: 14 } +preview_sample: + primary_id: "Erdbeermarmelade" + title: "Lager > Vorrat" + qr_payload: "https://grocy.example.com/stock/products/47" + secondary: ["MHD 2027-04-30", "3 Glaeser"] diff --git a/backend/app/seed/templates/grocy-24mm.yaml b/backend/app/seed/templates/grocy-24mm.yaml index 4de59fa..db996a0 100644 --- a/backend/app/seed/templates/grocy-24mm.yaml +++ b/backend/app/seed/templates/grocy-24mm.yaml @@ -9,3 +9,8 @@ elements: - { type: text, x: 260, y: 20, field: primary_id, font_size: 48 } - { type: text, x: 260, y: 85, field: title, font_size: 28 } - { type: text, x: 260, y: 130, field: secondary, font_size: 18 } +preview_sample: + primary_id: "Erdbeermarmelade" + title: "Lager > Vorrat" + qr_payload: "https://grocy.example.com/stock/products/47" + secondary: ["MHD 2027-04-30", "3 Glaeser"] diff --git a/backend/app/seed/templates/qr-only-12mm.yaml b/backend/app/seed/templates/qr-only-12mm.yaml index 97b70bc..8505e35 100644 --- a/backend/app/seed/templates/qr-only-12mm.yaml +++ b/backend/app/seed/templates/qr-only-12mm.yaml @@ -6,3 +6,7 @@ app: null tape_mm: 12 elements: - { type: qr, x: 260, y: 13, size: 80, data_field: qr_payload } +preview_sample: + primary_id: "Sample" + title: "Preview" + qr_payload: "https://example.com/preview" diff --git a/backend/app/seed/templates/qr-only-18mm.yaml b/backend/app/seed/templates/qr-only-18mm.yaml index e44dcd3..393960c 100644 --- a/backend/app/seed/templates/qr-only-18mm.yaml +++ b/backend/app/seed/templates/qr-only-18mm.yaml @@ -6,3 +6,7 @@ app: null tape_mm: 18 elements: - { type: qr, x: 230, y: 13, size: 140, data_field: qr_payload } +preview_sample: + primary_id: "Sample" + title: "Preview" + qr_payload: "https://example.com/preview" diff --git a/backend/app/seed/templates/qr-only-24mm.yaml b/backend/app/seed/templates/qr-only-24mm.yaml index 8324a25..cdb739b 100644 --- a/backend/app/seed/templates/qr-only-24mm.yaml +++ b/backend/app/seed/templates/qr-only-24mm.yaml @@ -6,3 +6,7 @@ app: null tape_mm: 24 elements: - { type: qr, x: 185, y: 13, size: 230, data_field: qr_payload } +preview_sample: + primary_id: "Sample" + title: "Preview" + qr_payload: "https://example.com/preview" diff --git a/backend/app/seed/templates/snipeit-12mm.yaml b/backend/app/seed/templates/snipeit-12mm.yaml index b85c94d..b231054 100644 --- a/backend/app/seed/templates/snipeit-12mm.yaml +++ b/backend/app/seed/templates/snipeit-12mm.yaml @@ -9,3 +9,7 @@ elements: - { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload } - { type: text, x: 100, y: 18, field: primary_id, font_size: 22 } - { type: text, x: 100, y: 60, field: title, font_size: 14 } +preview_sample: + primary_id: "ASSET-2024-001" + title: "Dell Latitude 7430" + qr_payload: "https://snipeit.example.com/hardware/123" diff --git a/backend/app/seed/templates/snipeit-18mm.yaml b/backend/app/seed/templates/snipeit-18mm.yaml index a041716..edba27f 100644 --- a/backend/app/seed/templates/snipeit-18mm.yaml +++ b/backend/app/seed/templates/snipeit-18mm.yaml @@ -9,3 +9,8 @@ elements: - { type: text, x: 170, y: 20, field: primary_id, font_size: 32 } - { type: text, x: 170, y: 70, field: title, font_size: 20 } - { type: text, x: 170, y: 110, field: secondary, font_size: 14 } +preview_sample: + primary_id: "ASSET-2024-001" + title: "Dell Latitude 7430" + qr_payload: "https://snipeit.example.com/hardware/123" + secondary: ["IT Office", "Bjoern Strausmann"] diff --git a/backend/app/seed/templates/snipeit-24mm.yaml b/backend/app/seed/templates/snipeit-24mm.yaml index 453fabd..7a2c312 100644 --- a/backend/app/seed/templates/snipeit-24mm.yaml +++ b/backend/app/seed/templates/snipeit-24mm.yaml @@ -9,3 +9,8 @@ elements: - { type: text, x: 260, y: 20, field: primary_id, font_size: 48 } - { type: text, x: 260, y: 85, field: title, font_size: 28 } - { type: text, x: 260, y: 130, field: secondary, font_size: 18 } +preview_sample: + primary_id: "ASSET-2024-001" + title: "Dell Latitude 7430" + qr_payload: "https://snipeit.example.com/hardware/123" + secondary: ["IT Office", "Bjoern Strausmann"] diff --git a/backend/app/seed/templates/spoolman-12mm.yaml b/backend/app/seed/templates/spoolman-12mm.yaml index 2e3d8f3..f8b192b 100644 --- a/backend/app/seed/templates/spoolman-12mm.yaml +++ b/backend/app/seed/templates/spoolman-12mm.yaml @@ -8,3 +8,7 @@ elements: - { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload } - { type: text, x: 100, y: 18, field: primary_id, font_size: 22 } - { type: text, x: 100, y: 60, field: title, font_size: 14 } +preview_sample: + primary_id: "PLA-Black-1kg" + title: "Spool #7" + qr_payload: "https://spoolman.example.com/spool/7" diff --git a/backend/app/seed/templates/spoolman-18mm.yaml b/backend/app/seed/templates/spoolman-18mm.yaml index 1b63efa..c793702 100644 --- a/backend/app/seed/templates/spoolman-18mm.yaml +++ b/backend/app/seed/templates/spoolman-18mm.yaml @@ -9,3 +9,8 @@ elements: - { type: text, x: 170, y: 20, field: primary_id, font_size: 32 } - { type: text, x: 170, y: 70, field: title, font_size: 20 } - { type: text, x: 170, y: 110, field: secondary, font_size: 14 } +preview_sample: + primary_id: "PLA-Black-1kg" + title: "Spool #7" + qr_payload: "https://spoolman.example.com/spool/7" + secondary: ["780g left", "Prusament"] diff --git a/backend/app/seed/templates/spoolman-24mm.yaml b/backend/app/seed/templates/spoolman-24mm.yaml index a571e90..1950bb4 100644 --- a/backend/app/seed/templates/spoolman-24mm.yaml +++ b/backend/app/seed/templates/spoolman-24mm.yaml @@ -9,3 +9,8 @@ elements: - { type: text, x: 260, y: 20, field: primary_id, font_size: 48 } - { type: text, x: 260, y: 85, field: title, font_size: 28 } - { type: text, x: 260, y: 130, field: secondary, font_size: 18 } +preview_sample: + primary_id: "PLA-Black-1kg" + title: "Spool #7" + qr_payload: "https://spoolman.example.com/spool/7" + secondary: ["780g left", "Prusament"] diff --git a/backend/tests/unit/api/test_printers_routes.py b/backend/tests/unit/api/test_printers_routes.py index 3ff8dc1..93d0868 100644 --- a/backend/tests/unit/api/test_printers_routes.py +++ b/backend/tests/unit/api/test_printers_routes.py @@ -892,6 +892,53 @@ async def test_get_printer_tape_direct_unknown_tape_size_raises_404(session) -> assert exc_info.value.status_code == 404 +@pytest.mark.asyncio +async def test_get_printer_detail_returns_full_metadata(session) -> None: + """GET /api/printers/{id} returns full printer metadata including paused flag. + + Regression for Bug 2 — the backend had no single-printer GET endpoint. + The frontend detail page showed only Status + Tape + Error, no metadata. + """ + printer = await _make_printer(session) + await _make_printer_state(session, printer.id, paused=False) + + app = _build_app(session) + client = TestClient(app, raise_server_exceptions=True) + r = client.get(f"/api/printers/{printer.id}") + + assert r.status_code == 200 + body = r.json() + required_fields = ( + "id", + "name", + "model", + "backend", + "connection", + "enabled", + "paused", + "created_at", + "updated_at", + ) + for field in required_fields: + assert field in body, f"missing field: {field}" + assert body["connection"]["host"] == "198.51.100.10" + assert body["connection"]["port"] == 9100 + assert body["paused"] is False + + +@pytest.mark.asyncio +async def test_get_printer_detail_unknown_id_returns_404(session) -> None: + """GET /api/printers/{id} returns 404 for an unknown UUID.""" + from uuid import uuid4 + + app = _build_app(session) + client = TestClient(app, raise_server_exceptions=True) + r = client.get(f"/api/printers/{uuid4()}") + + assert r.status_code == 404 + assert "not found" in r.json()["detail"] + + @pytest.mark.asyncio async def test_get_printer_queue_direct_returns_active_jobs(session) -> None: """get_printer_queue called directly returns QUEUED and PRINTING jobs. diff --git a/backend/tests/unit/api/test_templates_routes.py b/backend/tests/unit/api/test_templates_routes.py index 3468f8c..f1aab62 100644 --- a/backend/tests/unit/api/test_templates_routes.py +++ b/backend/tests/unit/api/test_templates_routes.py @@ -12,7 +12,7 @@ import app.models # noqa: F401 — registers all SQLModel tables with metadata import pytest import pytest_asyncio -from app.api.routes.templates import router +from app.api.routes.templates import render_router, router from app.db.engine import _apply_pragmas from app.db.session import get_session from app.models.template import Template @@ -58,6 +58,7 @@ def _build_app(session_override: AsyncSession) -> FastAPI: """Return a FastAPI app with the templates router and the DB overridden.""" app = FastAPI() app.include_router(router) + app.include_router(render_router) async def _override_session() -> AsyncIterator[AsyncSession]: yield session_override @@ -77,7 +78,11 @@ async def _make_template( name: str, app_name: str | None = None, source: str = "seed", + preview_sample: dict[str, object] | None = None, ) -> Template: + definition: dict[str, object] = {"elements": []} + if preview_sample is not None: + definition["preview_sample"] = preview_sample tpl = Template( key=key, name=name, @@ -85,7 +90,7 @@ async def _make_template( printer_model="PT-P750W", tape_width_mm=12, source=source, - definition={"elements": []}, + definition=definition, ) session.add(tpl) await session.commit() @@ -194,6 +199,136 @@ async def test_list_templates_direct_with_app_filter(session) -> None: assert result[0].app == "snipeit" +@pytest.mark.asyncio +async def test_template_preview_returns_png(session) -> None: + """POST /api/render/preview?key= renders a sample label as PNG bytes. + + Regression for Bug 3 — the backend had no preview endpoint. + The frontend template detail page fell back to preview-placeholder.svg + because POST /api/render/preview always returned 404. + """ + await _make_template( + session, + "snipeit/asset", + "Asset Label", + app_name="snipeit", + preview_sample={ + "primary_id": "ASSET-2024-001", + "title": "Dell Latitude 7430", + "qr_payload": "https://snipeit.example.com/hardware/123", + }, + ) + + app = _build_app(session) + client = TestClient(app, raise_server_exceptions=True) + r = client.post("/api/render/preview?key=snipeit%2Fasset") + + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" + assert r.content[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic number + + +@pytest.mark.asyncio +async def test_template_preview_unknown_key_returns_404(session) -> None: + """POST /api/render/preview?key= returns 404 for a missing template.""" + app = _build_app(session) + client = TestClient(app, raise_server_exceptions=True) + r = client.post("/api/render/preview?key=no-such-key") + + assert r.status_code == 404 + + +@pytest.mark.asyncio +async def test_template_preview_uses_preview_sample_from_definition(session) -> None: + """The preview endpoint reads sample values from template.definition.preview_sample. + + Regression for Commit 4 refactor — sample data must live in the template + definition, not be hardcoded per-app in the route. A template without + preview_sample must return 422; a template WITH preview_sample renders. + """ + await _make_template( + session, + "custom/key", + "Custom Template", + app_name=None, # no integration app — only works because preview_sample is on the template + preview_sample={ + "primary_id": "CUSTOM-1", + "title": "User-defined preview", + "qr_payload": "https://example.com/custom/1", + }, + ) + + app = _build_app(session) + client = TestClient(app, raise_server_exceptions=True) + r = client.post("/api/render/preview?key=custom%2Fkey") + + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" + assert r.content[:8] == b"\x89PNG\r\n\x1a\n" + + +@pytest.mark.asyncio +async def test_template_preview_renders_seed_template_via_loader_pipeline(session) -> None: + """End-to-end: a real seed YAML survives the TemplateLoader → seed_db pipeline + with its preview_sample intact, and the preview endpoint renders it. + + This guards against silent loss of preview_sample if a future refactor + breaks the schema_dump → DB → schema_construct round-trip. + """ + from pathlib import Path + + from app.integrations import _discover_plugins + from app.integrations.registry import IntegrationRegistry + from app.services.template_loader import TemplateLoader + + # IntegrationRegistry is a class-level singleton that other tests may have + # cleared. The seed-template loader validates `app` against the registry, + # so re-discover plugins here to make the test hermetic regardless of + # test ordering in the full suite. + if not IntegrationRegistry.names(): + _discover_plugins() + + seed_dir = Path(__file__).resolve().parents[3] / "app" / "seed" / "templates" + # The loader caches at the class level — clear first so the test is hermetic. + TemplateLoader._cache.clear() + TemplateLoader.load_dir(seed_dir) + await TemplateLoader.seed_db(session) + + app = _build_app(session) + client = TestClient(app, raise_server_exceptions=True) + r = client.post("/api/render/preview?key=snipeit-12mm") + + assert r.status_code == 200, r.text + assert r.headers["content-type"] == "image/png" + assert r.content[:8] == b"\x89PNG\r\n\x1a\n" + + +@pytest.mark.asyncio +async def test_template_preview_fails_when_template_lacks_preview_sample(session) -> None: + """Templates without preview_sample return 422 with a clear error message. + + The previous implementation guessed sample data per-app — wrong responsibility + locality. Templates must declare their own preview values; the route no + longer fabricates fallbacks. + """ + await _make_template( + session, + "incomplete/template", + "No Preview Sample", + app_name="snipeit", + preview_sample=None, # explicit: definition has no preview_sample block + ) + + app = _build_app(session) + client = TestClient(app, raise_server_exceptions=True) + r = client.post("/api/render/preview?key=incomplete%2Ftemplate") + + assert r.status_code == 422 + detail = r.json()["detail"] + assert "preview_sample" in detail + assert "incomplete/template" in detail + + @pytest.mark.asyncio async def test_list_templates_direct_filter_no_match_returns_empty(session) -> None: """list_templates with ?app= that matches nothing returns an empty list. diff --git a/frontend/internal/api/client.gen.go b/frontend/internal/api/client.gen.go index 49a6146..6021d06 100644 --- a/frontend/internal/api/client.gen.go +++ b/frontend/internal/api/client.gen.go @@ -114,7 +114,7 @@ type PrinterRead struct { Id openapi_types.UUID `json:"id"` Model string `json:"model"` Name string `json:"name"` - Paused *bool `json:"paused,omitempty"` + Paused bool `json:"paused"` UpdatedAt time.Time `json:"updated_at"` } @@ -221,6 +221,12 @@ type ListJobsApiJobsGetParams struct { // LookupApiLookupAppEntityIdGetParamsApp defines parameters for LookupApiLookupAppEntityIdGet. type LookupApiLookupAppEntityIdGetParamsApp string +// RenderPreviewApiRenderPreviewPostParams defines parameters for RenderPreviewApiRenderPreviewPost. +type RenderPreviewApiRenderPreviewPostParams struct { + // Key Template key, e.g. 'snipeit-12mm' + Key string `form:"key" json:"key"` +} + // ListTemplatesApiTemplatesGetParams defines parameters for ListTemplatesApiTemplatesGet. type ListTemplatesApiTemplatesGetParams struct { // App Filter by integration app (snipeit / grocy / spoolman / …) @@ -389,6 +395,9 @@ type ClientInterface interface { // ListPrintersApiPrintersGet request ListPrintersApiPrintersGet(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetPrinterApiPrintersPrinterIdGet request + GetPrinterApiPrintersPrinterIdGet(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + // PausePrinterApiPrintersPrinterIdPausePost request PausePrinterApiPrintersPrinterIdPausePost(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -407,6 +416,9 @@ type ClientInterface interface { // GetPrinterTapeApiPrintersPrinterIdTapeGet request GetPrinterTapeApiPrintersPrinterIdTapeGet(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + // RenderPreviewApiRenderPreviewPost request + RenderPreviewApiRenderPreviewPost(ctx context.Context, params *RenderPreviewApiRenderPreviewPostParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListTemplatesApiTemplatesGet request ListTemplatesApiTemplatesGet(ctx context.Context, params *ListTemplatesApiTemplatesGetParams, reqEditors ...RequestEditorFn) (*http.Response, error) } @@ -519,6 +531,18 @@ func (c *Client) ListPrintersApiPrintersGet(ctx context.Context, reqEditors ...R return c.Client.Do(req) } +func (c *Client) GetPrinterApiPrintersPrinterIdGet(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPrinterApiPrintersPrinterIdGetRequest(c.Server, printerId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) PausePrinterApiPrintersPrinterIdPausePost(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewPausePrinterApiPrintersPrinterIdPausePostRequest(c.Server, printerId) if err != nil { @@ -591,6 +615,18 @@ func (c *Client) GetPrinterTapeApiPrintersPrinterIdTapeGet(ctx context.Context, return c.Client.Do(req) } +func (c *Client) RenderPreviewApiRenderPreviewPost(ctx context.Context, params *RenderPreviewApiRenderPreviewPostParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRenderPreviewApiRenderPreviewPostRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListTemplatesApiTemplatesGet(ctx context.Context, params *ListTemplatesApiTemplatesGetParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListTemplatesApiTemplatesGetRequest(c.Server, params) if err != nil { @@ -981,6 +1017,40 @@ func NewListPrintersApiPrintersGetRequest(server string) (*http.Request, error) return req, nil } +// NewGetPrinterApiPrintersPrinterIdGetRequest generates requests for GetPrinterApiPrintersPrinterIdGet +func NewGetPrinterApiPrintersPrinterIdGetRequest(server string, printerId openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "printer_id", printerId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/printers/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewPausePrinterApiPrintersPrinterIdPausePostRequest generates requests for PausePrinterApiPrintersPrinterIdPausePost func NewPausePrinterApiPrintersPrinterIdPausePostRequest(server string, printerId openapi_types.UUID) (*http.Request, error) { var err error @@ -1185,6 +1255,56 @@ func NewGetPrinterTapeApiPrintersPrinterIdTapeGetRequest(server string, printerI return req, nil } +// NewRenderPreviewApiRenderPreviewPostRequest generates requests for RenderPreviewApiRenderPreviewPost +func NewRenderPreviewApiRenderPreviewPostRequest(server string, params *RenderPreviewApiRenderPreviewPostParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/render/preview") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "key", params.Key, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewListTemplatesApiTemplatesGetRequest generates requests for ListTemplatesApiTemplatesGet func NewListTemplatesApiTemplatesGetRequest(server string, params *ListTemplatesApiTemplatesGetParams) (*http.Request, error) { var err error @@ -1309,6 +1429,9 @@ type ClientWithResponsesInterface interface { // ListPrintersApiPrintersGetWithResponse request ListPrintersApiPrintersGetWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListPrintersApiPrintersGetResponse, error) + // GetPrinterApiPrintersPrinterIdGetWithResponse request + GetPrinterApiPrintersPrinterIdGetWithResponse(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetPrinterApiPrintersPrinterIdGetResponse, error) + // PausePrinterApiPrintersPrinterIdPausePostWithResponse request PausePrinterApiPrintersPrinterIdPausePostWithResponse(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*PausePrinterApiPrintersPrinterIdPausePostResponse, error) @@ -1327,6 +1450,9 @@ type ClientWithResponsesInterface interface { // GetPrinterTapeApiPrintersPrinterIdTapeGetWithResponse request GetPrinterTapeApiPrintersPrinterIdTapeGetWithResponse(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetPrinterTapeApiPrintersPrinterIdTapeGetResponse, error) + // RenderPreviewApiRenderPreviewPostWithResponse request + RenderPreviewApiRenderPreviewPostWithResponse(ctx context.Context, params *RenderPreviewApiRenderPreviewPostParams, reqEditors ...RequestEditorFn) (*RenderPreviewApiRenderPreviewPostResponse, error) + // ListTemplatesApiTemplatesGetWithResponse request ListTemplatesApiTemplatesGetWithResponse(ctx context.Context, params *ListTemplatesApiTemplatesGetParams, reqEditors ...RequestEditorFn) (*ListTemplatesApiTemplatesGetResponse, error) } @@ -1608,6 +1734,37 @@ func (r ListPrintersApiPrintersGetResponse) ContentType() string { return "" } +type GetPrinterApiPrintersPrinterIdGetResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PrinterRead + JSON422 *HTTPValidationError +} + +// Status returns HTTPResponse.Status +func (r GetPrinterApiPrintersPrinterIdGetResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetPrinterApiPrintersPrinterIdGetResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetPrinterApiPrintersPrinterIdGetResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type PausePrinterApiPrintersPrinterIdPausePostResponse struct { Body []byte HTTPResponse *http.Response @@ -1791,6 +1948,36 @@ func (r GetPrinterTapeApiPrintersPrinterIdTapeGetResponse) ContentType() string return "" } +type RenderPreviewApiRenderPreviewPostResponse struct { + Body []byte + HTTPResponse *http.Response + JSON422 *HTTPValidationError +} + +// Status returns HTTPResponse.Status +func (r RenderPreviewApiRenderPreviewPostResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RenderPreviewApiRenderPreviewPostResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RenderPreviewApiRenderPreviewPostResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListTemplatesApiTemplatesGetResponse struct { Body []byte HTTPResponse *http.Response @@ -1903,6 +2090,15 @@ func (c *ClientWithResponses) ListPrintersApiPrintersGetWithResponse(ctx context return ParseListPrintersApiPrintersGetResponse(rsp) } +// GetPrinterApiPrintersPrinterIdGetWithResponse request returning *GetPrinterApiPrintersPrinterIdGetResponse +func (c *ClientWithResponses) GetPrinterApiPrintersPrinterIdGetWithResponse(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetPrinterApiPrintersPrinterIdGetResponse, error) { + rsp, err := c.GetPrinterApiPrintersPrinterIdGet(ctx, printerId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPrinterApiPrintersPrinterIdGetResponse(rsp) +} + // PausePrinterApiPrintersPrinterIdPausePostWithResponse request returning *PausePrinterApiPrintersPrinterIdPausePostResponse func (c *ClientWithResponses) PausePrinterApiPrintersPrinterIdPausePostWithResponse(ctx context.Context, printerId openapi_types.UUID, reqEditors ...RequestEditorFn) (*PausePrinterApiPrintersPrinterIdPausePostResponse, error) { rsp, err := c.PausePrinterApiPrintersPrinterIdPausePost(ctx, printerId, reqEditors...) @@ -1957,6 +2153,15 @@ func (c *ClientWithResponses) GetPrinterTapeApiPrintersPrinterIdTapeGetWithRespo return ParseGetPrinterTapeApiPrintersPrinterIdTapeGetResponse(rsp) } +// RenderPreviewApiRenderPreviewPostWithResponse request returning *RenderPreviewApiRenderPreviewPostResponse +func (c *ClientWithResponses) RenderPreviewApiRenderPreviewPostWithResponse(ctx context.Context, params *RenderPreviewApiRenderPreviewPostParams, reqEditors ...RequestEditorFn) (*RenderPreviewApiRenderPreviewPostResponse, error) { + rsp, err := c.RenderPreviewApiRenderPreviewPost(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseRenderPreviewApiRenderPreviewPostResponse(rsp) +} + // ListTemplatesApiTemplatesGetWithResponse request returning *ListTemplatesApiTemplatesGetResponse func (c *ClientWithResponses) ListTemplatesApiTemplatesGetWithResponse(ctx context.Context, params *ListTemplatesApiTemplatesGetParams, reqEditors ...RequestEditorFn) (*ListTemplatesApiTemplatesGetResponse, error) { rsp, err := c.ListTemplatesApiTemplatesGet(ctx, params, reqEditors...) @@ -2249,6 +2454,39 @@ func ParseListPrintersApiPrintersGetResponse(rsp *http.Response) (*ListPrintersA return response, nil } +// ParseGetPrinterApiPrintersPrinterIdGetResponse parses an HTTP response from a GetPrinterApiPrintersPrinterIdGetWithResponse call +func ParseGetPrinterApiPrintersPrinterIdGetResponse(rsp *http.Response) (*GetPrinterApiPrintersPrinterIdGetResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetPrinterApiPrintersPrinterIdGetResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PrinterRead + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest HTTPValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + } + + return response, nil +} + // ParsePausePrinterApiPrintersPrinterIdPausePostResponse parses an HTTP response from a PausePrinterApiPrintersPrinterIdPausePostWithResponse call func ParsePausePrinterApiPrintersPrinterIdPausePostResponse(rsp *http.Response) (*PausePrinterApiPrintersPrinterIdPausePostResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -2426,6 +2664,32 @@ func ParseGetPrinterTapeApiPrintersPrinterIdTapeGetResponse(rsp *http.Response) return response, nil } +// ParseRenderPreviewApiRenderPreviewPostResponse parses an HTTP response from a RenderPreviewApiRenderPreviewPostWithResponse call +func ParseRenderPreviewApiRenderPreviewPostResponse(rsp *http.Response) (*RenderPreviewApiRenderPreviewPostResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RenderPreviewApiRenderPreviewPostResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest HTTPValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + } + + return response, nil +} + // ParseListTemplatesApiTemplatesGetResponse parses an HTTP response from a ListTemplatesApiTemplatesGetWithResponse call func ParseListTemplatesApiTemplatesGetResponse(rsp *http.Response) (*ListTemplatesApiTemplatesGetResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/frontend/internal/api/client.go b/frontend/internal/api/client.go index 5ac2bd0..b64c690 100644 --- a/frontend/internal/api/client.go +++ b/frontend/internal/api/client.go @@ -76,6 +76,27 @@ func (c *HubClient) ListPrinters(ctx context.Context) ([]PrinterRead, error) { return *resp.JSON200, nil } +// GetPrinterDetail returns full printer metadata from GET /api/printers/{id}. +func (c *HubClient) GetPrinterDetail(ctx context.Context, id string) (*PrinterRead, error) { + start := time.Now() + uid, err := parseUUID(id) + if err != nil { + return nil, ErrNotFound + } + resp, err := c.gen.GetPrinterApiPrintersPrinterIdGetWithResponse(ctx, uid) + logCall("GetPrinterDetail", start, err) + if err != nil { + return nil, err + } + if resp.StatusCode() == http.StatusNotFound { + return nil, ErrNotFound + } + if resp.JSON200 == nil { + return nil, fmt.Errorf("GetPrinterDetail: status %d", resp.StatusCode()) + } + return resp.JSON200, nil +} + // GetPrinterStatus returns a fresh printer status probe from GET /api/printers/{id}/status. func (c *HubClient) GetPrinterStatus(ctx context.Context, id string) (*PrinterStatus, error) { start := time.Now() diff --git a/frontend/internal/api/client_test.go b/frontend/internal/api/client_test.go index 4287459..efa8739 100644 --- a/frontend/internal/api/client_test.go +++ b/frontend/internal/api/client_test.go @@ -11,6 +11,47 @@ import ( "github.com/strausmann/label-printer-hub/frontend/internal/api" ) +// TestPrinterReadPausedFalseDecodesAsBoolFalse verifies that a JSON response +// with "paused": false decodes to PrinterRead.Paused == false (not a non-nil +// pointer to false, which would be truthy in Go template {{if .Paused}}). +// +// This is the regression test for Bug 1: oapi-codegen generated Paused *bool +// (omitempty) from the OpenAPI schema that listed paused as optional-with-default. +// A non-nil *bool(&false) evaluates as truthy in html/template {{if .Paused}}, +// causing every printer to show the "Paused" badge. +// After the fix: paused is required in the schema → Paused bool → false is falsy. +func TestPrinterReadPausedFalseDecodesAsBoolFalse(t *testing.T) { + t.Parallel() + now := time.Now().Format(time.RFC3339) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/printers" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]any{ + {"id": "aaaaaaaa-0000-0000-0000-000000000001", "name": "PT-P750W", + "model": "pt_series", "backend": "tcp", + "connection": map[string]any{"host": "198.51.100.10", "port": 9100}, + "enabled": true, "paused": false, "created_at": now, "updated_at": now}, + }) + } else { + http.NotFound(w, r) + } + })) + defer backend.Close() + + printers, err := api.NewHubClient(backend.URL).ListPrinters(context.Background()) + if err != nil { + t.Fatalf("ListPrinters: %v", err) + } + if len(printers) != 1 { + t.Fatalf("expected 1 printer, got %d", len(printers)) + } + // Paused must be a plain bool false — NOT a non-nil *bool(&false). + // A *bool is truthy in html/template {{if .Paused}} even when it points to false. + if printers[0].Paused != false { + t.Errorf("Paused = %v, want false (plain bool, not pointer-to-false)", printers[0].Paused) + } +} + func TestListPrintersHitsCorrectPath(t *testing.T) { t.Parallel() called := false diff --git a/frontend/internal/api/openapi.snapshot.json b/frontend/internal/api/openapi.snapshot.json index 797dc52..8ad96ce 100644 --- a/frontend/internal/api/openapi.snapshot.json +++ b/frontend/internal/api/openapi.snapshot.json @@ -1,7 +1,7 @@ { "openapi": "3.0.3", "info": { - "title": "Label Printer Hub — backend", + "title": "Label Printer Hub \u2014 backend", "version": "0.0.0-dev" }, "paths": { @@ -176,7 +176,7 @@ "printers" ], "summary": "Pause job dispatch for a printer", - "description": "Sets ``printer_state.paused = true`` for this printer. New jobs can still be queued but the worker will not dispatch them until the printer is resumed. Idempotent — pausing an already-paused printer returns 204 without error.", + "description": "Sets ``printer_state.paused = true`` for this printer. New jobs can still be queued but the worker will not dispatch them until the printer is resumed. Idempotent \u2014 pausing an already-paused printer returns 204 without error.", "operationId": "pause_printer_api_printers__printer_id__pause_post", "parameters": [ { @@ -213,7 +213,7 @@ "printers" ], "summary": "Resume job dispatch for a printer", - "description": "Sets ``printer_state.paused = false``. Idempotent — resuming an already-active printer returns 204 without error.", + "description": "Sets ``printer_state.paused = false``. Idempotent \u2014 resuming an already-active printer returns 204 without error.", "operationId": "resume_printer_api_printers__printer_id__resume_post", "parameters": [ { @@ -250,7 +250,7 @@ "printers" ], "summary": "Cancel all queued jobs for a printer", - "description": "Bulk-cancels every job in ``queued`` state for this printer. Jobs in ``printing`` state are intentionally **not** cancelled — a mid-print abort is unsafe over TCP/9100 because the raster data is already on the wire. Returns 204 even when there are no queued jobs.", + "description": "Bulk-cancels every job in ``queued`` state for this printer. Jobs in ``printing`` state are intentionally **not** cancelled \u2014 a mid-print abort is unsafe over TCP/9100 because the raster data is already on the wire. Returns 204 even when there are no queued jobs.", "operationId": "clear_printer_queue_api_printers__printer_id__queue_clear_post", "parameters": [ { @@ -295,12 +295,12 @@ "in": "query", "required": false, "schema": { - "description": "Filter by integration app (snipeit / grocy / spoolman / …)", + "description": "Filter by integration app (snipeit / grocy / spoolman / \u2026)", "title": "App", "type": "string", "nullable": true }, - "description": "Filter by integration app (snipeit / grocy / spoolman / …)" + "description": "Filter by integration app (snipeit / grocy / spoolman / \u2026)" } ], "responses": { @@ -345,12 +345,12 @@ "in": "query", "required": false, "schema": { - "description": "Filter by job state (queued / printing / done / failed / …)", + "description": "Filter by job state (queued / printing / done / failed / \u2026)", "title": "State", "type": "string", "nullable": true }, - "description": "Filter by job state (queued / printing / done / failed / …)" + "description": "Filter by job state (queued / printing / done / failed / \u2026)" }, { "name": "printer_id", @@ -471,7 +471,7 @@ "jobs" ], "summary": "Cancel a queued job", - "description": "Cancels a job that is in ``queued`` state. Returns 409 ProblemDetail when the job is in ``printing`` (or any other non-QUEUED) state — mid-print abort is unsafe over TCP/9100 because the raster data is already on the wire.", + "description": "Cancels a job that is in ``queued`` state. Returns 409 ProblemDetail when the job is in ``printing`` (or any other non-QUEUED) state \u2014 mid-print abort is unsafe over TCP/9100 because the raster data is already on the wire.", "operationId": "cancel_job_api_jobs__job_id__cancel_post", "parameters": [ { @@ -515,7 +515,7 @@ "jobs" ], "summary": "Pause a job (not yet implemented)", - "description": "Placeholder — returns 501 ProblemDetail. Mid-job pause will be implemented when the queue worker gains control-plane support for pausing an in-progress raster stream. This endpoint exists so the Phase 7 UI can wire to a stable URL.", + "description": "Placeholder \u2014 returns 501 ProblemDetail. Mid-job pause will be implemented when the queue worker gains control-plane support for pausing an in-progress raster stream. This endpoint exists so the Phase 7 UI can wire to a stable URL.", "operationId": "pause_job_api_jobs__job_id__pause_post", "parameters": [ { @@ -559,7 +559,7 @@ "jobs" ], "summary": "Resume a paused job (not yet implemented)", - "description": "Placeholder — returns 501 ProblemDetail. Resume will be implemented alongside pause in a later phase. This endpoint exists so the Phase 7 UI can wire to a stable URL.", + "description": "Placeholder \u2014 returns 501 ProblemDetail. Resume will be implemented alongside pause in a later phase. This endpoint exists so the Phase 7 UI can wire to a stable URL.", "operationId": "resume_job_api_jobs__job_id__resume_post", "parameters": [ { @@ -647,7 +647,7 @@ "lookup" ], "summary": "Resolve an integration entity", - "description": "Looks up an entity from the given integration app by its identifier. ``app`` must be one of ``snipeit``, ``grocy``, or ``spoolman`` — an unsupported value returns 422. Returns 404 ProblemDetail when the entity does not exist in the integration's backend. The ``url`` field is the deep-link to the entity in the integration's own web UI, suitable for embedding in a QR code or label.", + "description": "Looks up an entity from the given integration app by its identifier. ``app`` must be one of ``snipeit``, ``grocy``, or ``spoolman`` \u2014 an unsupported value returns 422. Returns 404 ProblemDetail when the entity does not exist in the integration's backend. The ``url`` field is the deep-link to the entity in the integration's own web UI, suitable for embedding in a QR code or label.", "operationId": "lookup_api_lookup__app___entity_id__get", "parameters": [ { @@ -705,7 +705,7 @@ "events" ], "summary": "Server-Sent Events stream for a printer", - "description": "Returns a ``text/event-stream`` response. Publishes ``job.state_changed``, ``printer.status``, and ``printer.tape_changed`` events as they occur. A keepalive comment is sent every 30 s when no events flow. Closes automatically after 5 minutes of inactivity. On reconnect the stream starts fresh — ``Last-Event-ID`` is observed but replay is deferred to Phase 7. Returns 404 if ``printer_id`` does not exist in the database. Returns 429 if the per-printer subscriber limit is reached.", + "description": "Returns a ``text/event-stream`` response. Publishes ``job.state_changed``, ``printer.status``, and ``printer.tape_changed`` events as they occur. A keepalive comment is sent every 30 s when no events flow. Closes automatically after 5 minutes of inactivity. On reconnect the stream starts fresh \u2014 ``Last-Event-ID`` is observed but replay is deferred to Phase 7. Returns 404 if ``printer_id`` does not exist in the database. Returns 429 if the per-printer subscriber limit is reached.", "operationId": "sse_events_api_events_get", "parameters": [ { @@ -735,6 +735,99 @@ } } } + }, + "/api/printers/{printer_id}": { + "get": { + "tags": [ + "printers" + ], + "summary": "Get printer detail", + "description": "Returns full metadata for a single printer, including the ``paused`` flag joined from ``printer_state``. Returns 404 when the printer is not registered.", + "operationId": "get_printer_api_printers__printer_id__get", + "parameters": [ + { + "name": "printer_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Printer Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrinterRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/render/preview": { + "post": { + "tags": [ + "templates" + ], + "summary": "Render a template preview as PNG", + "description": "Renders the named template with the sample values declared in the template's own ``preview_sample`` block and returns a PNG image. Returns 404 if the template key is not registered. Returns 422 if the template has no ``preview_sample`` block.", + "operationId": "render_preview_api_render_preview_post", + "parameters": [ + { + "name": "key", + "in": "query", + "required": true, + "schema": { + "description": "Template key, e.g. 'snipeit-12mm'", + "title": "Key", + "type": "string" + }, + "description": "Template key, e.g. 'snipeit-12mm'" + } + ], + "responses": { + "200": { + "description": "PNG image of the rendered sample label", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Template not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -859,7 +952,7 @@ "additionalProperties": true, "type": "object", "title": "Extra", - "description": "Integration-specific extras not covered by the core fields. Contents vary by app — see each integration's plugin docs." + "description": "Integration-specific extras not covered by the core fields. Contents vary by app \u2014 see each integration's plugin docs." } }, "type": "object", @@ -902,8 +995,7 @@ }, "paused": { "type": "boolean", - "title": "Paused", - "default": false + "title": "Paused" }, "created_at": { "type": "string", @@ -925,10 +1017,11 @@ "connection", "enabled", "created_at", - "updated_at" + "updated_at", + "paused" ], "title": "PrinterRead", - "description": "Full representation of a Printer row, augmented with the paused flag.\n\n``paused`` is joined from the ``printer_state`` table; it defaults to\n``False`` for printers whose state row was not yet created (safe — the\nDB lifespan helper creates state rows at startup, so this only matters\nin tests or during the very first boot)." + "description": "Full representation of a Printer row, augmented with the paused flag.\n\n``paused`` is joined from the ``printer_state`` table; it defaults to\n``False`` for printers whose state row was not yet created (safe \u2014 the\nDB lifespan helper creates state rows at startup, so this only matters\nin tests or during the very first boot)." }, "PrinterStatus": { "properties": { diff --git a/frontend/internal/handlers/base.go b/frontend/internal/handlers/base.go index 5d2cfa3..28e8ba6 100644 --- a/frontend/internal/handlers/base.go +++ b/frontend/internal/handlers/base.go @@ -183,7 +183,7 @@ var stubPageContent = map[string]string{ "dashboard": `{{define "content"}}
{{end}} {{define "dashboard-content"}}
{{range .Printers}}{{.Name}}{{end}}
{{end}}`, "printer": `{{define "content"}}
printer
{{end}} -{{define "printer-content"}}
printer
{{end}}`, +{{define "printer-content"}}{{if .Printer}}
{{if and (index .Printer.Connection "host") (index .Printer.Connection "port")}}{{index .Printer.Connection "host"}}:{{index .Printer.Connection "port"}}{{else if index .Printer.Connection "interface"}}{{index .Printer.Connection "interface"}}{{end}}
{{else}}
printer
{{end}}{{end}}`, "jobs": `{{define "content"}}
{{end}} {{define "jobs-content"}}
{{range .Jobs}}{{.State}}{{end}}
{{end}}`, "job": `{{define "content"}}
job
{{end}} diff --git a/frontend/internal/handlers/dashboard_test.go b/frontend/internal/handlers/dashboard_test.go index 6aab855..8d234a6 100644 --- a/frontend/internal/handlers/dashboard_test.go +++ b/frontend/internal/handlers/dashboard_test.go @@ -74,6 +74,57 @@ func TestDashboardOKFullPage(t *testing.T) { } } +func TestDashboardRendersOnlineBadgeWhenPausedFalse(t *testing.T) { + // Regression for Bug 1 — dashboard showed "Paused" badge for every printer + // because oapi-codegen generated Paused *bool (omitempty) from the OpenAPI + // spec that listed paused as optional-with-default. A non-nil pointer to + // false evaluates as truthy in {{if .Paused}}, so every printer showed the + // Paused badge regardless of the actual paused value. + // After the fix: paused is required in the schema → oapi-codegen emits + // Paused bool → {{if .Paused}} is false for false, and the badge is correct. + // + // Strengthened assertions (Round 2): verify that: + // - both printer names appear (data round-trips) + // - no Go pointer nil-value artefact appears in the output + // - the printer-grid wrapper is present (structural sanity) + t.Parallel() + backend := printersBackend(t) + defer backend.Close() + ph := handlers.NewPageHandlerFromURL(t, backend.URL) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + ph.Dashboard(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + body := w.Body.String() + + // The stub dashboard-content template renders Name for each printer. + // The real badge logic lives in the real template; here we verify the data + // round-trips correctly: Paused must be a plain bool so the handler's data + // struct can be inspected via the stub template that accesses .Printers. + // The mock backend sends paused=false for PT-P750W and paused=true for QL-800. + if !strings.Contains(body, "PT-P750W") { + t.Errorf("body missing PT-P750W (paused=false printer), got: %s", body) + } + if !strings.Contains(body, "QL-800") { + t.Errorf("body missing QL-800 (paused=true printer), got: %s", body) + } + + // The printer grid wrapper must be present — confirms the fragment was rendered. + if !strings.Contains(body, "printer-grid") { + t.Errorf("body missing printer-grid wrapper: %s", body) + } + + // Guard against *bool pointer nil-value rendering — a regression indicator. + if strings.Contains(body, "") { + t.Errorf("body contains : Paused is likely a *bool not dereferenced: %s", body) + } +} + func TestDashboard503WhenBackendDown(t *testing.T) { t.Parallel() backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/frontend/internal/handlers/printer.go b/frontend/internal/handlers/printer.go index 0f3dbfd..46dcd1b 100644 --- a/frontend/internal/handlers/printer.go +++ b/frontend/internal/handlers/printer.go @@ -13,6 +13,7 @@ import ( type PrinterDetailData struct { TemplateData PrinterID string + Printer *api.PrinterRead // metadata from GET /api/printers/{id} Status *api.PrinterStatus Tape map[string]any Queue []map[string]any @@ -24,20 +25,30 @@ func (h *PageHandler) PrinterDetail(w http.ResponseWriter, r *http.Request) { h.PrinterDetailWithID(w, r, chi.URLParam(r, "id")) } -// PrinterDetailWithID fetches printer status, tape, and queue in parallel using -// errgroup and renders the printer detail template. +// PrinterDetailWithID fetches printer detail, status, tape, and queue in parallel +// using errgroup and renders the printer detail template. // Exported so integration tests can call it directly with a known ID. func (h *PageHandler) PrinterDetailWithID(w http.ResponseWriter, r *http.Request, id string) { var ( - status *api.PrinterStatus - tape map[string]any - queue []map[string]any + printer *api.PrinterRead + status *api.PrinterStatus + tape map[string]any + queue []map[string]any ) g, ctx := errgroup.WithContext(r.Context()) + g.Go(func() (err error) { + printer, err = h.client.GetPrinterDetail(ctx, id) + return + }) g.Go(func() (err error) { status, err = h.client.GetPrinterStatus(ctx, id) + // Status may be a 404 on unknown printer; also non-fatal when just unavailable. + if errors.Is(err, api.ErrNotFound) { + status = nil + err = nil + } return }) g.Go(func() (err error) { @@ -51,6 +62,11 @@ func (h *PageHandler) PrinterDetailWithID(w http.ResponseWriter, r *http.Request }) g.Go(func() (err error) { queue, err = h.client.GetPrinterQueue(ctx, id) + // Queue absent is non-fatal. + if errors.Is(err, api.ErrNotFound) { + queue = nil + err = nil + } return }) @@ -63,9 +79,16 @@ func (h *PageHandler) PrinterDetailWithID(w http.ResponseWriter, r *http.Request return } + // If the printer detail itself was not found, return 404. + if printer == nil { + h.renderError(w, r, http.StatusNotFound, "Not Found", "printer not found: "+id) + return + } + h.renderPage(w, r, "printer", PrinterDetailData{ TemplateData: TemplateData{Version: h.version, ActiveNav: "dashboard"}, PrinterID: id, + Printer: printer, Status: status, Tape: tape, Queue: queue, diff --git a/frontend/internal/handlers/printer_test.go b/frontend/internal/handlers/printer_test.go index 87aa2e7..2a2630c 100644 --- a/frontend/internal/handlers/printer_test.go +++ b/frontend/internal/handlers/printer_test.go @@ -19,6 +19,12 @@ func printerDetailBackend(t *testing.T, id string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { + case "/api/printers/" + id: + json.NewEncoder(w).Encode(map[string]any{ + "id": id, "name": "PT-P750W", "model": "pt_series", "backend": "tcp", + "connection": map[string]any{"host": "198.51.100.10", "port": 9100}, + "enabled": true, "paused": false, "created_at": now, "updated_at": now, + }) case "/api/printers/" + id + "/status": json.NewEncoder(w).Encode(map[string]any{"printer_id": id, "online": true, "tape_loaded": "12mm black/clear", "error_state": nil, "captured_at": now}) case "/api/printers/" + id + "/tape": @@ -31,6 +37,50 @@ func printerDetailBackend(t *testing.T, id string) *httptest.Server { })) } +func TestPrinterDetailShowsMetadata(t *testing.T) { + // Regression for Bug 2 — the printer detail page had no metadata block. + // Verify the handler populates Printer in PrinterDetailData so the template + // can render model/host/enabled/paused/created/updated fields. + // + // Strengthened assertions (Round 2): verify specific metadata fields are + // surfaced in the rendered output, not just the container div. + t.Parallel() + backend := printerDetailBackend(t, testPrinterID) + defer backend.Close() + ph := handlers.NewPageHandlerFromURL(t, backend.URL) + + req := httptest.NewRequest(http.MethodGet, "/printers/"+testPrinterID, nil) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + ph.PrinterDetailWithID(w, req, testPrinterID) + + if w.Code != http.StatusOK { + t.Fatalf("status %d, body: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + + // Container div must be present. + if !strings.Contains(body, "printer-detail") { + t.Errorf("body missing 'printer-detail', got: %s", body) + } + + // The stub template now renders data-model so we can verify the model field + // round-trips correctly from the backend JSON to the template. + if !strings.Contains(body, "pt_series") { + t.Errorf("body missing model 'pt_series', got: %s", body) + } + + // Host:port must be rendered for a TCP-connected printer. + if !strings.Contains(body, "198.51.100.10:9100") { + t.Errorf("body missing host:port '198.51.100.10:9100', got: %s", body) + } + + // The enabled flag must be surfaced as data-enabled attribute. + if !strings.Contains(body, "data-enabled=\"true\"") { + t.Errorf("body missing data-enabled=true, got: %s", body) + } +} + func TestPrinterDetailOK(t *testing.T) { t.Parallel() backend := printerDetailBackend(t, testPrinterID) @@ -78,3 +128,59 @@ func TestPrinterDetailNotFound(t *testing.T) { t.Errorf("status %d, want 404", w.Code) } } + +// usbPrinterBackend serves printer metadata for a USB-connected printer +// (connection map has only "interface", no "host"/"port"). +func usbPrinterBackend(t *testing.T, id string) *httptest.Server { + t.Helper() + now := time.Now().Format(time.RFC3339) + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/printers/" + id: + json.NewEncoder(w).Encode(map[string]any{ + "id": id, "name": "QL-820NWB", "model": "ql_series", "backend": "usb", + "connection": map[string]any{"interface": "usb"}, + "enabled": true, "paused": false, "created_at": now, "updated_at": now, + }) + case "/api/printers/" + id + "/status": + json.NewEncoder(w).Encode(map[string]any{"printer_id": id, "online": false, "tape_loaded": nil, "error_state": nil, "captured_at": now}) + case "/api/printers/" + id + "/tape": + json.NewEncoder(w).Encode(map[string]any{"width_mm": 62}) + case "/api/printers/" + id + "/queue": + json.NewEncoder(w).Encode([]any{}) + default: + http.NotFound(w, r) + } + })) +} + +// TestPrinterDetailUSBConnection verifies that the printer detail page renders +// correctly for USB-connected printers (connection has "interface" but no +// "host"/"port"). The template must not crash and must surface the interface +// value instead of an empty host:port pair. +func TestPrinterDetailUSBConnection(t *testing.T) { + t.Parallel() + const usbID = "dddddddd-0000-0000-0000-000000000004" + backend := usbPrinterBackend(t, usbID) + defer backend.Close() + ph := handlers.NewPageHandlerFromURL(t, backend.URL) + + req := httptest.NewRequest(http.MethodGet, "/printers/"+usbID, nil) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + ph.PrinterDetailWithID(w, req, usbID) + + if w.Code != http.StatusOK { + t.Fatalf("USB printer status %d, body: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + // The stub template renders the interface value inside a usb-badge span. + if !strings.Contains(body, "usb-badge") { + t.Errorf("USB printer body missing usb-badge span, got: %s", body) + } + // Must NOT render an empty host:port pair (bug guard). + if strings.Contains(body, "host-port") { + t.Errorf("USB printer body must not contain host-port span, got: %s", body) + } +} diff --git a/frontend/web/templates/printer.html b/frontend/web/templates/printer.html index 05968df..807e730 100644 --- a/frontend/web/templates/printer.html +++ b/frontend/web/templates/printer.html @@ -6,6 +6,26 @@
+ {{if .Printer}} +
+

Metadata

+
+
Name
{{.Printer.Name}}
+
Model
{{.Printer.Model}}
+
Backend
{{.Printer.Backend}}
+ {{if and (index .Printer.Connection "host") (index .Printer.Connection "port")}} +
Host
{{index .Printer.Connection "host"}}:{{index .Printer.Connection "port"}}
+ {{else if index .Printer.Connection "interface"}} +
Interface
{{index .Printer.Connection "interface"}}
+ {{end}} +
Enabled
{{if .Printer.Enabled}}yes{{else}}no{{end}}
+
Paused
{{if .Printer.Paused}}yes{{else}}no{{end}}
+
Created
{{.Printer.CreatedAt}}
+
Updated
{{.Printer.UpdatedAt}}
+
+
+ {{end}} +