diff --git a/backend/alembic/versions/a0516c04278c_add_print_batches_table.py b/backend/alembic/versions/a0516c04278c_add_print_batches_table.py new file mode 100644 index 0000000..aa18721 --- /dev/null +++ b/backend/alembic/versions/a0516c04278c_add_print_batches_table.py @@ -0,0 +1,66 @@ +"""add print_batches table + +Revision ID: a0516c04278c +Revises: da865401716d +Create Date: 2026-05-30 15:18:58.348408 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a0516c04278c" +down_revision: str | Sequence[str] | None = "da865401716d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "print_batches", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("printer_id", sa.Uuid(), nullable=False), + sa.Column("job_ids", sa.JSON(), nullable=True), + sa.Column("created_by", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["printer_id"], + ["printers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_print_batches_created_at"), + "print_batches", + ["created_at"], + unique=False, + ) + op.create_index( + op.f("ix_print_batches_created_by"), + "print_batches", + ["created_by"], + unique=False, + ) + op.create_index( + op.f("ix_print_batches_printer_id"), + "print_batches", + ["printer_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_print_batches_printer_id"), table_name="print_batches") + op.drop_index(op.f("ix_print_batches_created_by"), table_name="print_batches") + op.drop_index(op.f("ix_print_batches_created_at"), table_name="print_batches") + op.drop_table("print_batches") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/da865401716d_add_printer_slug_column.py b/backend/alembic/versions/da865401716d_add_printer_slug_column.py new file mode 100644 index 0000000..3a7aed5 --- /dev/null +++ b/backend/alembic/versions/da865401716d_add_printer_slug_column.py @@ -0,0 +1,41 @@ +"""add printer slug column + +Revision ID: da865401716d +Revises: 20260518_phase7c_pat_prefix +Create Date: 2026-05-30 15:03:06.420359 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "da865401716d" +down_revision: str | Sequence[str] | None = "20260518_phase7c_pat_prefix" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "printers", + sa.Column( + "slug", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + server_default="", + ), + ) + # Backfill aus name: existierende Drucker erhalten einen slug + op.execute("UPDATE printers SET slug = LOWER(REPLACE(name, ' ', '-')) WHERE slug = ''") + op.create_index(op.f("ix_printers_slug"), "printers", ["slug"], unique=True) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_printers_slug"), table_name="printers") + op.drop_column("printers", "slug") diff --git a/backend/app/api/routes/batch.py b/backend/app/api/routes/batch.py new file mode 100644 index 0000000..1f6c640 --- /dev/null +++ b/backend/app/api/routes/batch.py @@ -0,0 +1,114 @@ +"""POST /api/print/{printer_key}/batch — best-effort batch print.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import AuthContext, check_printer_access +from app.auth.scope_deps import require_print +from app.db.session import get_session +from app.models.print_batch import PrintBatch +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + SnmpQueryError, +) +from app.repositories import print_batches as batches_repo +from app.repositories import printers as printers_repo +from app.schemas.print_batch import BatchRequest, BatchResponse +from app.services.batch_dispatch import dispatch_batch + +# SessionDep locally — Hub has no central app/api/deps.py module. +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +# prefix=/api → POST /api/print/{...}/batch. print.py has no prefix +# (POST /print), so this is a clean separation. +router = APIRouter(prefix="/api") + +_SYNC_ERROR_MAP: dict[type[Exception], str] = { + PrinterOfflineError: "printer_offline", + PrinterCoverOpenError: "printer_cover_open", + SnmpQueryError: "snmp_error", +} + + +@router.post( + "/print/{printer_key}/batch", + status_code=status.HTTP_202_ACCEPTED, + response_model=BatchResponse, + tags=["print"], + summary="Submit a batch of print jobs", + description=( + "Best-effort batch print. Validates each item individually and " + "returns per-item errors. Hardware preconditions (printer_offline, " + "cover_open) reject the entire batch with 409." + ), +) +async def create_batch( + printer_key: Annotated[str, Path(description="Printer slug or UUID")], + body: BatchRequest, + http: Request, + session: SessionDep, + auth: Annotated[AuthContext, Depends(require_print)], +) -> BatchResponse: + # 1. Resolve printer (404 if unknown slug/uuid) + printer = await printers_repo.resolve_by_slug_or_uuid(session, printer_key) + if printer is None: + raise HTTPException(404, detail={"error_code": "printer_not_found"}) + + # 2. ACL: api-key may be restricted to a subset of printer_ids + check_printer_access(auth, printer.id) + + # 3. Verify the resolved printer matches the singleton wired into + # app.state.print_service. The Hub is currently single-printer at + # startup (main.py wires PrintService to app.state.printer_id). + # If the URL slug points to a different printer row, the dispatch + # would silently route to the wrong device. Reject explicitly. + seeded_printer_id = getattr(http.app.state, "printer_id", None) + if seeded_printer_id is not None and printer.id != seeded_printer_id: + raise HTTPException( + 404, + detail={ + "error_code": "printer_not_active", + "error_message": ( + "Resolved printer is not the currently-seeded device. " + "Hub is single-printer at startup; multi-printer routing " + "is a future enhancement." + ), + }, + ) + + # 4. Best-effort dispatch + service = http.app.state.print_service + try: + job_ids, errors = await dispatch_batch(service, body.items) + except (PrinterOfflineError, PrinterCoverOpenError, SnmpQueryError) as exc: + raise HTTPException( + 409, + detail={ + "error_code": _SYNC_ERROR_MAP[type(exc)], + "error_message": str(exc), + }, + ) from exc + + # 5. Persist tracking row + # auth.subject_id does NOT exist — use api_key_id or source + created_by = str(auth.api_key_id) if auth.api_key_id else auth.source + batch_row = PrintBatch( + printer_id=printer.id, + job_ids=job_ids, + created_by=created_by, + ) + await batches_repo.create(session, batch_row) + + return BatchResponse( + batch_id=batch_row.id, + printer_id=printer.id, + queued_at=datetime.now(UTC).isoformat().replace("+00:00", "Z"), + job_ids=job_ids, + errors=errors, + ) diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index dafb4cf..833e7d7 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -28,7 +28,7 @@ from typing import Annotated, Any from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import AuthContext, check_printer_access @@ -80,12 +80,30 @@ async def _get_printer_or_404(session: AsyncSession, printer_id: UUID) -> Any: summary="List all printers", description=( "Returns every registered printer. The ``paused`` flag is joined " - "from ``printer_state``; it is ``false`` when no state row exists yet." + "from ``printer_state``; it is ``false`` when no state row exists yet. " + "Pass ``?slug=`` to filter to a single printer by exact slug match " + "(returns 404 when no printer with that slug exists)." ), ) -async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[PrinterRead]: - """List all printers with their pause state.""" - printers = await printers_repo.list_all(session) +async def list_printers( + session: SessionDep, + _auth: ReadAuthDep, + slug: Annotated[str | None, Query(description="Filter by exact slug")] = None, +) -> list[PrinterRead]: + """List all printers with their pause state, optionally filtered by slug.""" + if slug is not None: + printer = await printers_repo.get_by_slug(session, slug) + if printer is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error_code": "printer_not_found", + "error_message": f"slug={slug!r} not found", + }, + ) + printers = [printer] + else: + printers = await printers_repo.list_all(session) result: list[PrinterRead] = [] for p in printers: state = await printer_state_repo.get(session, p.id) @@ -94,6 +112,7 @@ async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[Printer PrinterRead( id=p.id, name=p.name, + slug=p.slug, model=p.model, backend=p.backend, connection=dict(p.connection), diff --git a/backend/app/main.py b/backend/app/main.py index 025d240..74d67be 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -75,6 +75,7 @@ import app.integrations as _integrations_init # triggers integration plugin discovery from app import __version__ from app.api.error_handlers import register_error_handlers +from app.api.routes import batch as batch_routes from app.api.routes import events as events_routes from app.api.routes import jobs as jobs_routes from app.api.routes import lookup as lookup_routes @@ -595,6 +596,7 @@ async def readiness( register_error_handlers(app) app.include_router(print_router) + app.include_router(batch_routes.router) app.include_router(events_routes.router) app.include_router(printers_routes.router) app.include_router(templates_routes.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c1474c9..714088c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.api_key import ApiKey from app.models.job import Job, JobState from app.models.preset import Preset +from app.models.print_batch import PrintBatch from app.models.printer import Printer from app.models.printer_state import PrinterState from app.models.printer_status_cache import PrinterStatusCache @@ -17,6 +18,7 @@ "Job", "JobState", "Preset", + "PrintBatch", "Printer", "PrinterState", "PrinterStatusCache", diff --git a/backend/app/models/print_batch.py b/backend/app/models/print_batch.py new file mode 100644 index 0000000..9fae2a1 --- /dev/null +++ b/backend/app/models/print_batch.py @@ -0,0 +1,22 @@ +"""SQLModel table für print_batches — Tracking-Aggregat für Batch-Druckaufträge.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, DateTime +from sqlmodel import Column, Field, SQLModel + + +class PrintBatch(SQLModel, table=True): + __tablename__ = "print_batches" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + printer_id: UUID = Field(index=True, foreign_key="printers.id") + job_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + created_by: str = Field(index=True, description="SSO-Email oder API-Key-ID") + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC), + sa_column=Column(DateTime(timezone=True), nullable=False, index=True), + ) diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index 024704d..9656510 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -15,6 +15,13 @@ class Printer(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str = Field(index=True, unique=True) + slug: str = Field( + default="", + index=True, + unique=True, + description="Stable URL-safe identifier (e.g., 'brother-p750w'). " + "Defaults to slugified name on init.", + ) model: str backend: str connection: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) diff --git a/backend/app/repositories/print_batches.py b/backend/app/repositories/print_batches.py new file mode 100644 index 0000000..3c4b256 --- /dev/null +++ b/backend/app/repositories/print_batches.py @@ -0,0 +1,43 @@ +"""CRUD für PrintBatch-Aggregat.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col + +from app.models.print_batch import PrintBatch + + +async def create(session: AsyncSession, batch: PrintBatch) -> PrintBatch: + session.add(batch) + await session.commit() + await session.refresh(batch) + return batch + + +async def get(session: AsyncSession, batch_id: UUID) -> PrintBatch | None: + return await session.get(PrintBatch, batch_id) + + +async def list_recent(session: AsyncSession, hours: int = 24) -> list[PrintBatch]: + since = datetime.now(UTC) - timedelta(hours=hours) + result = await session.execute( + select(PrintBatch) + .where(col(PrintBatch.created_at) >= since) + .order_by(col(PrintBatch.created_at).desc()) + ) + return list(result.scalars()) + + +async def prune_older_than(session: AsyncSession, hours: int = 24) -> int: + cutoff = datetime.now(UTC) - timedelta(hours=hours) + result = await session.execute(select(PrintBatch).where(col(PrintBatch.created_at) < cutoff)) + rows = list(result.scalars()) + for row in rows: + await session.delete(row) + await session.commit() + return len(rows) diff --git a/backend/app/repositories/printers.py b/backend/app/repositories/printers.py index 712c965..9685504 100644 --- a/backend/app/repositories/printers.py +++ b/backend/app/repositories/printers.py @@ -34,3 +34,21 @@ async def create(session: AsyncSession, printer: Printer) -> Printer: await session.commit() await session.refresh(printer) return printer + + +async def get_by_slug(session: AsyncSession, slug: str) -> Printer | None: + """Lookup nach slug. None wenn nicht vorhanden.""" + result = await session.execute(select(Printer).where(col(Printer.slug) == slug)) + return result.scalar_one_or_none() + + +async def resolve_by_slug_or_uuid(session: AsyncSession, key: str) -> Printer | None: + """Akzeptiert Slug-String ODER UUID-String. UUID hat Vorrang (Performance).""" + try: + uuid_obj = UUID(key) + printer = await get(session, uuid_obj) + if printer is not None: + return printer + except ValueError: + pass + return await get_by_slug(session, key) diff --git a/backend/app/schemas/print_batch.py b/backend/app/schemas/print_batch.py new file mode 100644 index 0000000..d239f96 --- /dev/null +++ b/backend/app/schemas/print_batch.py @@ -0,0 +1,38 @@ +"""Pydantic-Schemas für POST /api/print/{slug_or_uuid}/batch.""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.print_request import PrintRequest + + +class BatchRequest(BaseModel): + """Top-level POST /api/print/{slug_or_uuid}/batch body.""" + + model_config = ConfigDict(extra="forbid") + items: Annotated[list[PrintRequest], Field(min_length=1, max_length=500)] + + +class BatchError(BaseModel): + """Pro-Item-Fehler in der Batch-Response.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + index: Annotated[int, Field(ge=0)] + error_code: str + error_message: str + error_detail: dict[str, object] | None = None + + +class BatchResponse(BaseModel): + """202 Response für erfolgreich akzeptierte Batch (auch wenn 0 Items queued).""" + + model_config = ConfigDict(extra="forbid") + batch_id: UUID + printer_id: UUID + queued_at: str # ISO-8601 mit Z-Suffix + job_ids: list[str] + errors: list[BatchError] = Field(default_factory=list) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 01d6227..b8dafe3 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -27,6 +27,7 @@ class PrinterRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID + slug: str = "" name: str model: str backend: str diff --git a/backend/app/seed/templates/hangar-furniture-12mm.yaml b/backend/app/seed/templates/hangar-furniture-12mm.yaml new file mode 100644 index 0000000..4d6213a --- /dev/null +++ b/backend/app/seed/templates/hangar-furniture-12mm.yaml @@ -0,0 +1,16 @@ +# Hangar Furniture Tag — 12mm Endlostape (TZe-231) +# 180 DPI: 12mm ≈ 85 px Höhe. +# Layout: QR links (75x75), primary_id oben rechts, title kleiner darunter. +schema_version: 1 +id: hangar-furniture-12mm +name: "Hangar Furniture Tag (TZe-231, 12mm)" +app: null +tape_mm: 12 +elements: + - { type: qr, x: 5, y: 5, size: 75, data_field: qr_payload } + - { type: text, x: 90, y: 12, field: primary_id, font_size: 20 } + - { type: text, x: 90, y: 50, field: title, font_size: 14 } +preview_sample: + primary_id: "HH-AK-KX10-F0203" + title: "Kallax Nr.10 Fach 2-3" + qr_payload: "https://hangar.example.test/loc/HH-AK-KX10-F0203" diff --git a/backend/app/seed/templates/hangar-furniture-18mm.yaml b/backend/app/seed/templates/hangar-furniture-18mm.yaml new file mode 100644 index 0000000..9e8e45a --- /dev/null +++ b/backend/app/seed/templates/hangar-furniture-18mm.yaml @@ -0,0 +1,15 @@ +# Hangar Furniture Hauptschild — 18mm extrastark (TZe-S251) +# 180 DPI: 18mm ≈ 128 px Höhe. +schema_version: 1 +id: hangar-furniture-18mm +name: "Hangar Furniture Hauptschild (TZe-S251, 18mm extrastark)" +app: null +tape_mm: 18 +elements: + - { type: qr, x: 8, y: 8, size: 110, data_field: qr_payload } + - { type: text, x: 130, y: 18, field: primary_id, font_size: 30 } + - { type: text, x: 130, y: 75, field: title, font_size: 18 } +preview_sample: + primary_id: "HH-VK-BY03" + title: "Billy VK Nr.3 Hauptschild" + qr_payload: "https://hangar.example.test/loc/HH-VK-BY03" diff --git a/backend/app/seed/templates/hangar-furniture-24mm.yaml b/backend/app/seed/templates/hangar-furniture-24mm.yaml new file mode 100644 index 0000000..63847a8 --- /dev/null +++ b/backend/app/seed/templates/hangar-furniture-24mm.yaml @@ -0,0 +1,15 @@ +# Hangar Furniture Tag — 24mm (TZe-251) +# 180 DPI: 24mm ≈ 170 px Höhe. +schema_version: 1 +id: hangar-furniture-24mm +name: "Hangar Furniture Tag (TZe-251, 24mm)" +app: null +tape_mm: 24 +elements: + - { type: qr, x: 10, y: 10, size: 150, data_field: qr_payload } + - { type: text, x: 175, y: 20, field: primary_id, font_size: 40 } + - { type: text, x: 175, y: 100, field: title, font_size: 24 } +preview_sample: + primary_id: "HH-AK-RAUM01" + title: "Arbeitskeller Raum" + qr_payload: "https://hangar.example.test/loc/HH-AK-RAUM01" diff --git a/backend/app/services/batch_dispatch.py b/backend/app/services/batch_dispatch.py new file mode 100644 index 0000000..8a3e633 --- /dev/null +++ b/backend/app/services/batch_dispatch.py @@ -0,0 +1,79 @@ +"""Best-effort Batch-Dispatcher: validiert + queued pro Item, sammelt Errors.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from app.printer_backends.exceptions import ( + PrinterCoverOpenError, + PrinterOfflineError, + SnmpQueryError, + TapeEmptyError, + TapeMismatchError, +) +from app.schemas.print_batch import BatchError +from app.schemas.print_request import PrintRequest +from app.services.lookup_service import LookupFailedError +from app.services.template_loader import TemplateNotFoundError + +if TYPE_CHECKING: + from app.services.print_service import PrintService + +_log = logging.getLogger(__name__) + +# Per-item errors → collected into BatchError list (best-effort) +_PER_ITEM_ERRORS: dict[type[Exception], str] = { + TemplateNotFoundError: "template_not_found", + LookupFailedError: "integration_lookup_failed", + TapeMismatchError: "tape_mismatch", + TapeEmptyError: "tape_empty", +} + +# Hardware preconditions → propagate (caller returns 409) +_BATCH_FATAL_ERRORS: tuple[type[Exception], ...] = ( + PrinterCoverOpenError, + PrinterOfflineError, + SnmpQueryError, +) + + +async def dispatch_batch( + service: PrintService, + items: list[PrintRequest], +) -> tuple[list[str], list[BatchError]]: + """Queue each item individually. Collect per-item errors. + Hardware errors propagate.""" + job_ids: list[str] = [] + errors: list[BatchError] = [] + + for index, item in enumerate(items): + try: + job_id = await service.submit_print_job(item) + job_ids.append(str(job_id)) + except _BATCH_FATAL_ERRORS: + raise + except tuple(_PER_ITEM_ERRORS) as exc: + code = _PER_ITEM_ERRORS[type(exc)] + detail: dict[str, object] | None = None + if isinstance(exc, TapeMismatchError): + detail = {"expected_mm": exc.expected_mm, "loaded_mm": exc.loaded_mm} + errors.append( + BatchError( + index=index, + error_code=code, + error_message=str(exc), + error_detail=detail, + ) + ) + except Exception as exc: # unknown sync failure + _log.exception("unexpected error in batch item %d", index) + errors.append( + BatchError( + index=index, + error_code="internal_error", + error_message=str(exc), + ) + ) + + return job_ids, errors diff --git a/backend/app/templates/fragments/job_state.html b/backend/app/templates/fragments/job_state.html index d4e9425..4290b39 100644 --- a/backend/app/templates/fragments/job_state.html +++ b/backend/app/templates/fragments/job_state.html @@ -1,4 +1,4 @@ -
+
{{ to_state | replace('_', ' ') | title }} {% if queue_depth > 0 %} {{ queue_depth }} job{% if queue_depth != 1 %}s{% endif %} queued diff --git a/backend/tests/db/test_lifespan.py b/backend/tests/db/test_lifespan.py index 78a3033..0eaaeaf 100644 --- a/backend/tests/db/test_lifespan.py +++ b/backend/tests/db/test_lifespan.py @@ -29,8 +29,11 @@ async def _make_printer(session, *, name: str = "pt-office") -> Printer: + # slug must be unique per Printer — derive from name to avoid + # UNIQUE constraint conflicts when several helpers run in one test. p = Printer( name=name, + slug=name.lower().replace(" ", "-").replace("_", "-"), model="pt-series", backend="ptouch", connection={"interface": "usb"}, diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index c83c650..b421b78 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -7,18 +7,24 @@ from __future__ import annotations +from uuid import uuid4 + import app.db.engine as _engine_module import app.db.lifespan as _lifespan_module import app.main as _main_module import app.models # noqa: F401 — registers all models with SQLModel.metadata import pytest import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read from app.config import get_settings from app.db.engine import _apply_pragmas +from app.main import create_app from app.printer_backends import BackendRegistry from app.printer_models.registry import ModelRegistry +from httpx import ASGITransport, AsyncClient from sqlalchemy import event -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlmodel import SQLModel @@ -188,3 +194,65 @@ def _mock_backend_env(monkeypatch: pytest.MonkeyPatch) -> None: BackendRegistry._discovered = False ModelRegistry._models.clear() ModelRegistry._discovered = False + + +@pytest_asyncio.fixture +async def app_with_fake_auth(): + """FastAPI-App mit gefakter Auth (dependency_overrides für require_*). + + Pattern verifiziert gegen tests/integration/test_print_e2e.py:50-70. + Gibt den _LifespanManager zurück — den nutzt der AsyncClient mit + ASGITransport. dependency_overrides werden auf der inneren FastAPI-Instanz + gesetzt (app._app), nicht auf dem Wrapper. + """ + app = create_app() + inner = app._app # FastAPI hinter _LifespanManager (main.py:517+612) + fake = AuthContext( + source="api-key", + scope="admin", + api_key_id=uuid4(), + ip="127.0.0.1", + ) + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + return app # _LifespanManager — wichtig für ASGITransport! + + +@pytest_asyncio.fixture +async def client(app_with_fake_auth): + """ASGI-Test-Client gegen die App mit gefakter Auth. + + `_temp_db_engine` (autouse) hat bereits eine Per-Test-SQLite-DB + vorbereitet und in app.db.engine + app.main gepatcht. Diese Fixture + setzt nur Auth-Overrides und einen httpx-Client darüber. + """ + async with AsyncClient( + transport=ASGITransport(app=app_with_fake_auth), + base_url="http://t", + ) as c: + yield c + + +@pytest_asyncio.fixture +async def db_session() -> AsyncSession: + """DB-Session gegen die per-test temp-Engine aus `_temp_db_engine`. + + Liest async_session dynamisch aus dem Engine-Modul, damit der + monkeypatch.setattr aus _temp_db_engine wirksam ist. + """ + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.fixture +def print_auth_headers() -> dict: + """Leere Dict — Auth ist via dependency_overrides gefakt.""" + return {} + + +@pytest.fixture +def read_auth_headers() -> dict: + """Leere Dict — siehe print_auth_headers.""" + return {} diff --git a/backend/tests/integration/db/test_print_batches_repo.py b/backend/tests/integration/db/test_print_batches_repo.py new file mode 100644 index 0000000..c257cb4 --- /dev/null +++ b/backend/tests/integration/db/test_print_batches_repo.py @@ -0,0 +1,71 @@ +"""print_batches-Tabelle: create + get + list_recent.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from app.models.print_batch import PrintBatch +from app.models.printer import Printer +from app.repositories import print_batches as batches_repo +from sqlalchemy.ext.asyncio import AsyncSession + + +async def _create_printer(session: AsyncSession) -> Printer: + """Hilfsfunktion: legt einen minimalen Printer an (FK-Pflicht für printer_id).""" + p = Printer( + name=f"Test-Printer-{uuid4().hex[:8]}", + slug=f"test-{uuid4().hex[:8]}", + model="PT-P750W", + backend="mock", + ) + session.add(p) + await session.commit() + await session.refresh(p) + return p + + +@pytest.mark.asyncio +async def test_create_and_get(db_session: AsyncSession): + printer = await _create_printer(db_session) + + batch_id = uuid4() + job_ids = [str(uuid4()) for _ in range(3)] + b = PrintBatch( + id=batch_id, printer_id=printer.id, job_ids=job_ids, created_by="björn@example.test" + ) + await batches_repo.create(db_session, b) + + found = await batches_repo.get(db_session, batch_id) + assert found is not None + assert found.printer_id == printer.id + assert len(found.job_ids) == 3 + + +@pytest.mark.asyncio +async def test_list_recent_24h(db_session: AsyncSession): + printer1 = await _create_printer(db_session) + printer2 = await _create_printer(db_session) + + now = datetime.now(UTC) + b1 = PrintBatch( + id=uuid4(), + printer_id=printer1.id, + job_ids=["j1"], + created_by="u", + created_at=now - timedelta(hours=2), + ) + b2 = PrintBatch( + id=uuid4(), + printer_id=printer2.id, + job_ids=["j2"], + created_by="u", + created_at=now - timedelta(hours=48), + ) + await batches_repo.create(db_session, b1) + await batches_repo.create(db_session, b2) + + recent = await batches_repo.list_recent(db_session, hours=24) + assert len(recent) == 1 + assert recent[0].id == b1.id diff --git a/backend/tests/integration/db/test_printer_slug_resolution.py b/backend/tests/integration/db/test_printer_slug_resolution.py new file mode 100644 index 0000000..64f657b --- /dev/null +++ b/backend/tests/integration/db/test_printer_slug_resolution.py @@ -0,0 +1,50 @@ +"""Repository-Tests für slug-Lookup und Slug-oder-UUID-Resolution.""" + +from __future__ import annotations + +import pytest +from app.models.printer import Printer +from app.repositories import printers as printers_repo +from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.mark.asyncio +async def test_get_by_slug_returns_printer(db_session: AsyncSession): + p = Printer(name="Brother PT-P750W", slug="brother-p750w", model="PT-P750W", backend="ptouch") + await printers_repo.create(db_session, p) + + found = await printers_repo.get_by_slug(db_session, "brother-p750w") + assert found is not None + assert found.id == p.id + + +@pytest.mark.asyncio +async def test_get_by_slug_returns_none_when_missing(db_session: AsyncSession): + found = await printers_repo.get_by_slug(db_session, "does-not-exist") + assert found is None + + +@pytest.mark.asyncio +async def test_resolve_by_slug_or_uuid_with_uuid(db_session: AsyncSession): + p = Printer(name="X", slug="x", model="X", backend="mock") + await printers_repo.create(db_session, p) + + found = await printers_repo.resolve_by_slug_or_uuid(db_session, str(p.id)) + assert found is not None + assert found.id == p.id + + +@pytest.mark.asyncio +async def test_resolve_by_slug_or_uuid_with_slug(db_session: AsyncSession): + p = Printer(name="Y", slug="my-printer", model="Y", backend="mock") + await printers_repo.create(db_session, p) + + found = await printers_repo.resolve_by_slug_or_uuid(db_session, "my-printer") + assert found is not None + assert found.id == p.id + + +@pytest.mark.asyncio +async def test_resolve_by_slug_or_uuid_with_garbage(db_session: AsyncSession): + found = await printers_repo.resolve_by_slug_or_uuid(db_session, "nonexistent") + assert found is None diff --git a/backend/tests/integration/test_batch_endpoint_auth.py b/backend/tests/integration/test_batch_endpoint_auth.py new file mode 100644 index 0000000..6984977 --- /dev/null +++ b/backend/tests/integration/test_batch_endpoint_auth.py @@ -0,0 +1,91 @@ +"""Auth-Matrix: kein Key → 401, read-Scope → 403, print-Scope → 202. + +NOTE: 401/403 tests cannot be done with dependency_overrides — the +conftest fixtures set dependency_overrides for ALL require_* deps. +Testing genuine 401/403 requires a client WITHOUT overrides. That +would need a separate fixture and is covered by Phase 7c auth tests. +Only the positive 202 case is exercised here. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from app.models.printer import Printer +from app.repositories import printers as printers_repo +from httpx import ASGITransport, AsyncClient + +_BODY = { + "items": [ + { + "template_id": "hangar-furniture-24mm", + "data": {"title": "x", "primary_id": "x", "qr_payload": "q"}, + } + ] +} + + +@pytest_asyncio.fixture +async def auth_client(): + """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. + + Yields (client, inner_app) so tests can set inner_app.state.printer_id + to align with the single-printer-binding check in batch.py. + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + # Touch the app once so lifespan runs and state is populated + await c.get("/healthz") + yield c, inner + + +@pytest_asyncio.fixture +async def auth_db_session(): + """DB-Session gegen die per-test temp-Engine.""" + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.mark.asyncio +async def test_batch_requires_auth(auth_client, auth_db_session): + """Genuine 401 requires unauthenticated client; covered by Phase 7c auth tests.""" + pytest.skip("401 requires unauthenticated client; covered by Phase 7c auth tests") + + +@pytest.mark.asyncio +async def test_batch_print_scope_allowed( + auth_client, + auth_db_session, +): + client, inner_app = auth_client + p = Printer(name="X", slug="x", model="X", backend="mock") + await printers_repo.create(auth_db_session, p) + # Align app state with our test printer (single-printer-binding check) + inner_app.state.printer_id = p.id + + resp = await client.post("/api/print/x/batch", json=_BODY) + assert resp.status_code == 202, resp.text diff --git a/backend/tests/integration/test_batch_endpoint_happy.py b/backend/tests/integration/test_batch_endpoint_happy.py new file mode 100644 index 0000000..8b23063 --- /dev/null +++ b/backend/tests/integration/test_batch_endpoint_happy.py @@ -0,0 +1,96 @@ +"""Happy-Path: 3 valide Items, alle queued, 0 errors.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from app.models.printer import Printer +from app.repositories import printers as printers_repo +from httpx import ASGITransport, AsyncClient + + +@pytest_asyncio.fixture +async def batch_client(): + """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. + + Propagiert den _temp_db_engine-Patch (autouse) in app.db.session. + Analoges Muster zu test_printers_filter_by_slug.py::slug_client. + + Yields (client, inner_app) so tests can set inner_app.state.printer_id + to align with the single-printer-binding check in batch.py. + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + # Propagate the monkeypatched engine into session.py's name binding + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + # Touch the app once so lifespan runs and state is populated + await c.get("/healthz") + yield c, inner + + +@pytest_asyncio.fixture +async def batch_db_session(): + """DB-Session gegen die per-test temp-Engine (analog zu conftest.db_session).""" + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.fixture +def batch_auth_headers() -> dict: + """Leere Dict — Auth ist via dependency_overrides gefakt.""" + return {} + + +@pytest.mark.asyncio +async def test_batch_happy_path(batch_client, batch_db_session, batch_auth_headers): + client, inner_app = batch_client + p = Printer(name="Brother PT-P750W", slug="brother-p750w", model="PT-P750W", backend="mock") + await printers_repo.create(batch_db_session, p) + # Align app state with our test printer (single-printer-binding check) + inner_app.state.printer_id = p.id + + # Mock backend defaults to 24mm loaded tape → use 24mm template to avoid mismatch + body = { + "items": [ + { + "template_id": "hangar-furniture-24mm", + "data": { + "title": f"Item {i}", + "primary_id": f"HH-AK-KX10-F{i:04d}", + "qr_payload": f"https://hangar.test/loc/HH-AK-KX10-F{i:04d}", + }, + "options": {"copies": 1, "auto_cut": True}, + } + for i in range(3) + ] + } + resp = await client.post(f"/api/print/{p.slug}/batch", json=body, headers=batch_auth_headers) + assert resp.status_code == 202, resp.text + data = resp.json() + assert "batch_id" in data + assert data["printer_id"] == str(p.id) + assert len(data["job_ids"]) == 3 + assert data["errors"] == [] diff --git a/backend/tests/integration/test_batch_endpoint_partial_failure.py b/backend/tests/integration/test_batch_endpoint_partial_failure.py new file mode 100644 index 0000000..8fefe0b --- /dev/null +++ b/backend/tests/integration/test_batch_endpoint_partial_failure.py @@ -0,0 +1,93 @@ +"""Partial: 3 Items, 1 mit unbekanntem template_id → 2 queued, 1 error.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from app.models.printer import Printer +from app.repositories import printers as printers_repo +from httpx import ASGITransport, AsyncClient + + +@pytest_asyncio.fixture +async def partial_client(): + """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. + + Yields (client, inner_app) so tests can set inner_app.state.printer_id + to align with the single-printer-binding check in batch.py. + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + # Touch the app once so lifespan runs and state is populated + await c.get("/healthz") + yield c, inner + + +@pytest_asyncio.fixture +async def partial_db_session(): + """DB-Session gegen die per-test temp-Engine.""" + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.fixture +def partial_auth_headers() -> dict: + return {} + + +@pytest.mark.asyncio +async def test_batch_partial_failure(partial_client, partial_db_session, partial_auth_headers): + client, inner_app = partial_client + p = Printer(name="Brother PT-P750W", slug="brother-p750w", model="PT-P750W", backend="mock") + await printers_repo.create(partial_db_session, p) + # Align app state with our test printer (single-printer-binding check) + inner_app.state.printer_id = p.id + + # Mock backend loads 24mm → use 24mm for valid items, unknown ID for the failing one + body = { + "items": [ + { + "template_id": "hangar-furniture-24mm", + "data": {"title": "A", "primary_id": "A", "qr_payload": "qA"}, + }, + { + "template_id": "does-not-exist", + "data": {"title": "B", "primary_id": "B", "qr_payload": "qB"}, + }, + { + "template_id": "hangar-furniture-24mm", + "data": {"title": "C", "primary_id": "C", "qr_payload": "qC"}, + }, + ] + } + resp = await client.post(f"/api/print/{p.slug}/batch", json=body, headers=partial_auth_headers) + assert resp.status_code == 202, resp.text + data = resp.json() + assert len(data["job_ids"]) == 2 + assert len(data["errors"]) == 1 + assert data["errors"][0]["index"] == 1 + assert data["errors"][0]["error_code"] == "template_not_found" diff --git a/backend/tests/integration/test_batch_endpoint_printer_offline.py b/backend/tests/integration/test_batch_endpoint_printer_offline.py new file mode 100644 index 0000000..ddb41af --- /dev/null +++ b/backend/tests/integration/test_batch_endpoint_printer_offline.py @@ -0,0 +1,92 @@ +"""Offline-Printer → 409 für ganzen Batch, kein Job queued.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from app.models.printer import Printer +from app.printer_backends.exceptions import PrinterOfflineError +from app.repositories import printers as printers_repo +from app.services.print_service import PrintService +from httpx import ASGITransport, AsyncClient + + +@pytest_asyncio.fixture +async def offline_client(): + """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. + + Yields (client, inner_app) so tests can set inner_app.state.printer_id + to align with the single-printer-binding check in batch.py. + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + # Touch the app once so lifespan runs and state is populated + await c.get("/healthz") + yield c, inner + + +@pytest_asyncio.fixture +async def offline_db_session(): + """DB-Session gegen die per-test temp-Engine.""" + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.fixture +def offline_auth_headers() -> dict: + return {} + + +@pytest.mark.asyncio +async def test_batch_rejects_when_printer_offline( + offline_client, + offline_db_session, + offline_auth_headers, + monkeypatch, +): + client, inner_app = offline_client + p = Printer(name="Brother PT-P750W", slug="brother-p750w", model="PT-P750W", backend="mock") + await printers_repo.create(offline_db_session, p) + # Align app state with our test printer (single-printer-binding check) + inner_app.state.printer_id = p.id + + async def _raise(self, req): + raise PrinterOfflineError("printer is offline") + + monkeypatch.setattr(PrintService, "submit_print_job", _raise) + + body = { + "items": [ + { + "template_id": "hangar-furniture-24mm", + "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, + } + ] + } + resp = await client.post(f"/api/print/{p.slug}/batch", json=body, headers=offline_auth_headers) + assert resp.status_code == 409, resp.text + assert resp.json()["detail"]["error_code"] == "printer_offline" diff --git a/backend/tests/integration/test_batch_endpoint_tape_mismatch.py b/backend/tests/integration/test_batch_endpoint_tape_mismatch.py new file mode 100644 index 0000000..05a9d64 --- /dev/null +++ b/backend/tests/integration/test_batch_endpoint_tape_mismatch.py @@ -0,0 +1,111 @@ +"""Mix 12mm + 24mm, eingelegt 24mm: 24mm queued, 12mm failed per-item.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from app.models.printer import Printer +from app.printer_backends.exceptions import TapeMismatchError +from app.repositories import printers as printers_repo +from app.services.print_service import PrintService +from httpx import ASGITransport, AsyncClient + + +@pytest_asyncio.fixture +async def tape_client(): + """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. + + Yields (client, inner_app) so tests can set inner_app.state.printer_id + to align with the single-printer-binding check in batch.py. + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + # Touch the app once so lifespan runs and state is populated + await c.get("/healthz") + yield c, inner + + +@pytest_asyncio.fixture +async def tape_db_session(): + """DB-Session gegen die per-test temp-Engine.""" + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.fixture +def tape_auth_headers() -> dict: + return {} + + +@pytest.mark.asyncio +async def test_batch_tape_mismatch_per_item( + tape_client, + tape_db_session, + tape_auth_headers, + monkeypatch, +): + client, inner_app = tape_client + p = Printer(name="Brother PT-P750W", slug="brother-p750w", model="PT-P750W", backend="mock") + await printers_repo.create(tape_db_session, p) + # Align app state with our test printer (single-printer-binding check) + inner_app.state.printer_id = p.id + + # Simulate: 24mm loaded, 12mm items fail with tape_mismatch, 24mm items succeed + async def _maybe_raise(self, req): + if req.template_id == "hangar-furniture-12mm": + raise TapeMismatchError(expected_mm=12, loaded_mm=24) + return str(uuid4()) + + monkeypatch.setattr(PrintService, "submit_print_job", _maybe_raise) + + body = { + "items": [ + { + "template_id": "hangar-furniture-24mm", + "data": {"title": "A", "primary_id": "A", "qr_payload": "q"}, + "on_tape_mismatch": "fail", + }, + { + "template_id": "hangar-furniture-12mm", + "data": {"title": "B", "primary_id": "B", "qr_payload": "q"}, + "on_tape_mismatch": "fail", + }, + { + "template_id": "hangar-furniture-24mm", + "data": {"title": "C", "primary_id": "C", "qr_payload": "q"}, + "on_tape_mismatch": "fail", + }, + ] + } + resp = await client.post(f"/api/print/{p.slug}/batch", json=body, headers=tape_auth_headers) + assert resp.status_code == 202, resp.text + data = resp.json() + assert len(data["job_ids"]) == 2 + assert len(data["errors"]) == 1 + assert data["errors"][0]["index"] == 1 + assert data["errors"][0]["error_code"] == "tape_mismatch" + assert data["errors"][0]["error_detail"] == {"expected_mm": 12, "loaded_mm": 24} diff --git a/backend/tests/integration/test_phase6b_sse_with_batch.py b/backend/tests/integration/test_phase6b_sse_with_batch.py new file mode 100644 index 0000000..7d3ac88 --- /dev/null +++ b/backend/tests/integration/test_phase6b_sse_with_batch.py @@ -0,0 +1,232 @@ +"""SSE-Generator zeigt Events für alle Jobs eines Batches. + +Strategie: _sse_stream direkt aufrufen (NICHT client.stream, weil ASGITransport +SSE-Streams buffert — siehe test_phase6b_sse.py:7-16). + +Die Fixtures spiegeln das Muster aus test_batch_endpoint_happy.py — eigene +lokale Fixtures statt conftest-Fixtures, weil _temp_db_engine (autouse) nur +unter bestimmten Bedingungen korrekt in die session-Bindung propagiert wird. +Nach dem Batch-Submit wird _sse_stream direkt mit dem bus aus app._app.state +aufgerufen, damit derselbe EventBus den die PrintQueue beschreibt auch +abonniert wird. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import uuid +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from httpx import ASGITransport, AsyncClient + +# --------------------------------------------------------------------------- +# Local fixtures — mirror test_batch_endpoint_happy.py pattern +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def sse_batch_app(): + """_LifespanManager mit gefakter Auth und propagierter temp-DB-Engine. + + Gibt den _LifespanManager zurück damit ASGITransport die Lifespan + ausführt (event_bus + print_service werden dort gestartet). + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + # Propagate the monkeypatched engine into session.py's name binding + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + return app + + +@pytest_asyncio.fixture +async def sse_batch_client(sse_batch_app): + """AsyncClient gegen die App — Lifespan startet bei erster Anfrage.""" + async with AsyncClient(transport=ASGITransport(app=sse_batch_app), base_url="http://t") as c: + yield c + + +@pytest_asyncio.fixture +async def sse_batch_db_session(): + """DB-Session gegen die per-test temp-Engine (analog zu conftest.db_session).""" + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.fixture +def sse_batch_auth_headers() -> dict: + """Leere Dict — Auth ist via dependency_overrides gefakt.""" + return {} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_request() -> object: + """Minimaler mock Request — _sse_stream liest headers und ruft is_disconnected.""" + from unittest.mock import MagicMock + + disconnect_flag = asyncio.Event() + + async def _is_disconnected() -> bool: + return disconnect_flag.is_set() + + req = MagicMock() + req.headers = {} + req.client = None + req.is_disconnected = _is_disconnected + req._disconnect_flag = disconnect_flag + return req + + +# --------------------------------------------------------------------------- +# T-B5: SSE-Stream enthält Events für alle Jobs eines Batches +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sse_contains_batch_job_events( + sse_batch_client: AsyncClient, + sse_batch_app, + sse_batch_db_session, + sse_batch_auth_headers: dict, +) -> None: + """Submit Batch → PrintQueue publiziert Events → _sse_stream emittiert sie. + + Ablauf: + 1. Lifespan triggern (erster Request) — event_bus, print_service und + app.state.printer_id werden initialisiert (zufällige UUID, da kein Host). + 2. Printer-Row in der DB mit derselben ID anlegen (id=app.state.printer_id), + damit batch.py's single-printer-binding-Check printer.id == seeded_printer_id + besteht, ohne dass wir die internen PrintQueue-Strukturen umpatchem müssen. + 3. App-internen EventBus und printer_id aus app._app.state lesen. + 4. _sse_stream direkt aufrufen (umgeht ASGITransport-Buffering). + 5. Batch submiten und warten bis alle 3 job_ids in SSE-Frames erscheinen (≤5 s). + """ + import app.api.routes.events as events_mod + from app.models.printer import Printer + from app.repositories import printers as printers_repo + + # 1. Lifespan triggern (erster Request startet event_bus + print_service) + warmup = await sse_batch_client.get("/healthz") + assert warmup.status_code == 200, f"warmup failed: {warmup.status_code}" + + # 2. EventBus + printer_id aus app state lesen — Lifespan hat gestartet. + inner = sse_batch_app._app # FastAPI-Instanz hinter _LifespanManager + bus = inner.state.event_bus + # printer_id aus dem Lifespan — PrintQueueProducer schreibt auf + # f"printer:{printer_id}:queue" + app_printer_id: uuid.UUID = inner.state.printer_id + + # 3. Printer-Row mit ID=app_printer_id in die DB schreiben. + # batch.py prüft printer.id == app.state.printer_id — durch die identische + # ID passt der Check ohne dass PrintQueue-Interna umgebaut werden müssen. + p = Printer( + id=app_printer_id, + name="Brother PT-P750W", + slug="brother-p750w", + model="PT-P750W", + backend="mock", + ) + await printers_repo.create(sse_batch_db_session, p) + + channels = [ + f"printer:{app_printer_id}:queue", + f"printer:{app_printer_id}:state", + f"printer:{app_printer_id}:tape", + ] + subscriber_id = "test-b5-sse-batch" + request = _mock_request() + + # 4. _sse_stream ZUERST starten (Bypass ASGITransport-Buffering) + # Die Subscription muss VOR dem Batch-Submit aktiv sein, sonst + # werden Events emittiert bevor wir abonniert haben. + gen = events_mod._sse_stream( + app_printer_id, + bus, + request, + subscriber_id, + channels, + ) + + seen_jobs: set[str] = set() + frame_queue: asyncio.Queue[str] = asyncio.Queue() + + async def pump() -> None: + async for frame in gen: + await frame_queue.put(frame) + + pump_task = asyncio.create_task(pump()) + # Generator warmlaufen lassen bis subscribe() + asyncio.wait erreicht + await asyncio.sleep(0.1) + + # 5. Batch submiten — jetzt ist die Subscription aktiv + body = { + "items": [ + { + "template_id": "hangar-furniture-24mm", + "data": { + "title": f"T{i}", + "primary_id": f"P{i}", + "qr_payload": "https://hangar.test/q", + }, + } + for i in range(3) + ] + } + resp = await sse_batch_client.post( + f"/api/print/{p.slug}/batch", + json=body, + headers=sse_batch_auth_headers, + ) + assert resp.status_code == 202, resp.text + job_ids: set[str] = set(resp.json()["job_ids"]) + assert len(job_ids) == 3, f"expected 3 job_ids, got {resp.json()['job_ids']}" + + # 6. Frames konsumieren bis alle job_ids gesehen oder Timeout + deadline = asyncio.get_event_loop().time() + 5.0 + while asyncio.get_event_loop().time() < deadline and seen_jobs < job_ids: + try: + frame = await asyncio.wait_for(frame_queue.get(), timeout=0.2) + for jid in job_ids: + if jid in frame: + seen_jobs.add(jid) + except TimeoutError: + continue + + # Teardown — Disconnect signalisieren + request._disconnect_flag.set() # type: ignore[attr-defined] + pump_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await pump_task + with contextlib.suppress(Exception): + await gen.aclose() + + assert seen_jobs == job_ids, ( + f"SSE-Stream enthielt keine Events für: {job_ids - seen_jobs}\n" + f"Gesehene job_ids: {seen_jobs}" + ) diff --git a/backend/tests/integration/test_printers_filter_by_slug.py b/backend/tests/integration/test_printers_filter_by_slug.py new file mode 100644 index 0000000..53b4967 --- /dev/null +++ b/backend/tests/integration/test_printers_filter_by_slug.py @@ -0,0 +1,104 @@ +"""GET /api/printers?slug=... filtert auf einzelnen Printer.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +import pytest_asyncio +from app.auth.dependencies import AuthContext +from app.auth.scope_deps import require_admin, require_print, require_read +from app.models.printer import Printer +from app.repositories import printers as printers_repo +from httpx import ASGITransport, AsyncClient + + +@pytest_asyncio.fixture +async def slug_client(): + """AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session. + + Propagiert den _temp_db_engine-Patch (autouse, setzt eng_mod.async_session) + in app.db.session (Name-Binding, wird NICHT automatisch aktualisiert). + Analog zu tests/integration/api/conftest.py::api_client_with_seed. + """ + import app.db.engine as _eng + import app.db.session as _sess + from app.integrations import ( # type: ignore[attr-defined] + IntegrationRegistry, + _discover_plugins, + ) + from app.main import create_app + + # Propagate the monkeypatched engine into session.py's name binding + _sess.async_session = _eng.async_session + + if not IntegrationRegistry.names(): + _discover_plugins() + + fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1") + app = create_app() + inner = app._app + for dep in (require_read, require_print, require_admin): + inner.dependency_overrides[dep] = lambda _c=fake: _c + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + yield c + + +@pytest_asyncio.fixture +async def slug_db_session(): + """DB-Session gegen die per-test temp-Engine (analog zu conftest.db_session).""" + import app.db.engine as eng_mod + + async with eng_mod.async_session() as s: + yield s + + +@pytest.fixture +def read_auth_headers() -> dict: + """Leere Dict — Auth ist via dependency_overrides gefakt.""" + return {} + + +@pytest.mark.asyncio +async def test_filter_by_slug_returns_one( + slug_client: AsyncClient, + slug_db_session, + read_auth_headers, +): + await printers_repo.create( + slug_db_session, + Printer(name="Brother PT-P750W", slug="brother-p750w", model="PT-P750W", backend="ptouch"), + ) + await printers_repo.create( + slug_db_session, + Printer( + name="Brother QL-820NWB", slug="brother-ql820nwb", model="QL-820NWB", backend="ptouch" + ), + ) + + resp = await slug_client.get("/api/printers?slug=brother-p750w", headers=read_auth_headers) + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 1 + assert body[0]["slug"] == "brother-p750w" + + +@pytest.mark.asyncio +async def test_filter_by_slug_returns_404_when_missing(slug_client, read_auth_headers): + resp = await slug_client.get("/api/printers?slug=unknown", headers=read_auth_headers) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_no_filter_returns_all(slug_client: AsyncClient, slug_db_session, read_auth_headers): + await printers_repo.create( + slug_db_session, Printer(name="A", slug="a", model="X", backend="mock") + ) + await printers_repo.create( + slug_db_session, Printer(name="B", slug="b", model="X", backend="mock") + ) + + resp = await slug_client.get("/api/printers", headers=read_auth_headers) + assert resp.status_code == 200 + assert len(resp.json()) >= 2 diff --git a/backend/tests/unit/api/test_jobs_routes.py b/backend/tests/unit/api/test_jobs_routes.py index bc2c8f3..14980ce 100644 --- a/backend/tests/unit/api/test_jobs_routes.py +++ b/backend/tests/unit/api/test_jobs_routes.py @@ -93,8 +93,11 @@ async def _override_session() -> AsyncIterator[AsyncSession]: async def _make_printer(session: AsyncSession, name: str | None = None) -> Printer: global _printer_counter _printer_counter += 1 + resolved_name = name or f"test-printer-{_printer_counter}" p = Printer( - name=name or f"test-printer-{_printer_counter}", + name=resolved_name, + # slug must be unique — derive from name to avoid UNIQUE conflicts. + slug=resolved_name.lower().replace(" ", "-").replace("_", "-"), model="pt-series", backend="ptouch", connection={"host": "198.51.100.10", "port": 9100}, diff --git a/backend/tests/unit/seed/test_hangar_templates.py b/backend/tests/unit/seed/test_hangar_templates.py new file mode 100644 index 0000000..5e11a48 --- /dev/null +++ b/backend/tests/unit/seed/test_hangar_templates.py @@ -0,0 +1,38 @@ +"""Verifiziert dass die 3 Hangar-Templates valide YAML sind und beim Seed landen.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from app.schemas.template import TemplateSchema + +SEED_DIR = Path(__file__).parents[3] / "app" / "seed" / "templates" + + +@pytest.mark.parametrize( + "tape_mm,template_id", + [ + (12, "hangar-furniture-12mm"), + (18, "hangar-furniture-18mm"), + (24, "hangar-furniture-24mm"), + ], +) +def test_hangar_template_parses(tape_mm: int, template_id: str): + path = SEED_DIR / f"{template_id}.yaml" + assert path.exists(), f"missing {path}" + + raw = yaml.safe_load(path.read_text()) + assert raw["schema_version"] == 1 + assert raw["id"] == template_id + assert raw["app"] is None, "app must be null (IntegrationRegistry kennt hangar nicht)" + assert raw["tape_mm"] == tape_mm + + tmpl = TemplateSchema(**raw) + assert tmpl is not None + types = [e["type"] for e in raw["elements"]] + assert types.count("qr") == 1 + assert types.count("text") >= 2 + for elem in raw["elements"]: + assert "bold" not in elem, f"'bold' is not a valid hub element field: {elem}" diff --git a/backend/tests/unit/seed/test_seed_templates.py b/backend/tests/unit/seed/test_seed_templates.py index 1e395a7..4bcdedd 100644 --- a/backend/tests/unit/seed/test_seed_templates.py +++ b/backend/tests/unit/seed/test_seed_templates.py @@ -20,18 +20,21 @@ SEED_DIR = Path(__file__).parent.parent.parent.parent / "app" / "seed" / "templates" EXPECTED_IDS = { - "snipeit-12mm", - "snipeit-18mm", - "snipeit-24mm", - "spoolman-12mm", - "spoolman-18mm", - "spoolman-24mm", "grocy-12mm", "grocy-18mm", "grocy-24mm", + "hangar-furniture-12mm", + "hangar-furniture-18mm", + "hangar-furniture-24mm", "qr-only-12mm", "qr-only-18mm", "qr-only-24mm", + "snipeit-12mm", + "snipeit-18mm", + "snipeit-24mm", + "spoolman-12mm", + "spoolman-18mm", + "spoolman-24mm", } @@ -68,7 +71,7 @@ def dummy_data() -> LabelData: def test_all_expected_templates_are_loaded() -> None: - """The shipped set is exactly the 12 templates the spec calls for.""" + """The shipped set is exactly the 15 templates the spec calls for.""" assert set(TemplateLoader.all()) == EXPECTED_IDS diff --git a/backend/tests/unit/test_batch_dispatch.py b/backend/tests/unit/test_batch_dispatch.py new file mode 100644 index 0000000..8a40e0e --- /dev/null +++ b/backend/tests/unit/test_batch_dispatch.py @@ -0,0 +1,49 @@ +"""Unit-Tests für den Batch-Dispatcher (best-effort, pro-Item-Validation).""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from app.schemas.print_request import PrintRequest, RawLabelData +from app.services.batch_dispatch import dispatch_batch +from app.services.template_loader import TemplateNotFoundError + + +class _FakePrintService: + def __init__(self, fail_at: dict[int, type[Exception]] | None = None): + self.fail_at = fail_at or {} + self.calls = 0 + + async def submit_print_job(self, req: PrintRequest): + idx = self.calls + self.calls += 1 + if idx in self.fail_at: + raise self.fail_at[idx]("simulated") + return str(uuid4()) + + +def _item(template_id="hangar-furniture-12mm"): + return PrintRequest( + template_id=template_id, data=RawLabelData(title="t", primary_id="p", qr_payload="q") + ) + + +@pytest.mark.asyncio +async def test_dispatch_all_succeed(): + service = _FakePrintService() + items = [_item() for _ in range(3)] + job_ids, errors = await dispatch_batch(service, items) + assert len(job_ids) == 3 + assert errors == [] + + +@pytest.mark.asyncio +async def test_dispatch_partial_failure_keeps_going(): + service = _FakePrintService(fail_at={1: TemplateNotFoundError}) + items = [_item(), _item("typo"), _item()] + job_ids, errors = await dispatch_batch(service, items) + assert len(job_ids) == 2 + assert len(errors) == 1 + assert errors[0].index == 1 + assert errors[0].error_code == "template_not_found" diff --git a/backend/tests/unit/test_batch_schema.py b/backend/tests/unit/test_batch_schema.py new file mode 100644 index 0000000..8402a4f --- /dev/null +++ b/backend/tests/unit/test_batch_schema.py @@ -0,0 +1,56 @@ +"""BatchRequest/Response/Error Schema-Validation.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from app.schemas.print_batch import BatchError, BatchRequest, BatchResponse +from app.schemas.print_request import PrintRequest, RawLabelData +from pydantic import ValidationError + + +def _sample_item() -> PrintRequest: + return PrintRequest( + template_id="hangar-furniture-12mm", + data=RawLabelData( + title="Kallax 10 Fach 2-3", + primary_id="HH-AK-KX10-F0203", + qr_payload="https://hangar.test/loc/HH-AK-KX10-F0203", + ), + ) + + +def test_batch_request_accepts_items(): + req = BatchRequest(items=[_sample_item(), _sample_item()]) + assert len(req.items) == 2 + + +def test_batch_request_rejects_empty(): + with pytest.raises(ValidationError, match="at least 1 item"): + BatchRequest(items=[]) + + +def test_batch_request_caps_at_500(): + items = [_sample_item() for _ in range(501)] + with pytest.raises(ValidationError, match="at most 500"): + BatchRequest(items=items) + + +def test_batch_response_with_all_succeeded(): + resp = BatchResponse( + batch_id=uuid4(), + printer_id=uuid4(), + queued_at="2026-05-30T19:42:01Z", + job_ids=[str(uuid4()) for _ in range(5)], + errors=[], + ) + assert len(resp.job_ids) == 5 + assert resp.errors == [] + + +def test_batch_error_with_required_fields(): + err = BatchError( + index=3, error_code="template_not_found", error_message="Template 'x' does not exist" + ) + assert err.index == 3 diff --git a/backend/tests/unit/test_job_state_fragment.py b/backend/tests/unit/test_job_state_fragment.py new file mode 100644 index 0000000..4d3c55e --- /dev/null +++ b/backend/tests/unit/test_job_state_fragment.py @@ -0,0 +1,47 @@ +"""Verifiziert dass das job_state.html-Fragment data-job-id und data-state trägt.""" + +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +import pytest +from jinja2 import Environment, FileSystemLoader + + +@pytest.fixture +def jinja_env(): + # Hub-Templates liegen unter backend/app/templates/ + template_dir = Path(__file__).parent.parent.parent / "app" / "templates" + # autoescape=True via select_autoescape default — no functional change + # for our UUID/string substitutions, satisfies CodeQL py/jinja2-autoescape. + return Environment( + loader=FileSystemLoader(str(template_dir)), + autoescape=True, + ) + + +def test_job_state_fragment_has_data_job_id_and_state(jinja_env): + """Das Root-
muss data-job-id und data-state tragen. + + Render-Context: Top-Level-Vars wie sie der Hub-Producer in event.data legt + (job_id, from_state, to_state, queue_depth, error_code, timestamp). + """ + job_id = str(uuid4()) + tmpl = jinja_env.get_template("fragments/job_state.html") + rendered = tmpl.render( + job_id=job_id, + from_state="queued", + to_state="printing", + queue_depth=0, + error_code=None, + timestamp="2026-05-30T12:00:00Z", + ) + assert f'data-job-id="{job_id}"' in rendered, ( + "job_state.html muss data-job-id auf dem Root-Element tragen " + "damit der Hangar SSE-Proxy filtern kann" + ) + assert 'data-state="printing"' in rendered, ( + "job_state.html muss data-state auf dem Root-Element tragen " + "damit Hangar's parseHubEvent den State extrahieren kann (Spec §5.4)" + ) diff --git a/backend/tests/unit/test_printer_schema_slug.py b/backend/tests/unit/test_printer_schema_slug.py new file mode 100644 index 0000000..8216ef7 --- /dev/null +++ b/backend/tests/unit/test_printer_schema_slug.py @@ -0,0 +1,24 @@ +"""PrinterRead exposiert slug-Feld.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +from app.schemas.printer import PrinterRead + + +def test_printer_read_has_slug_field(): + now = datetime.now(UTC) + payload = { + "id": str(uuid4()), + "slug": "brother-p750w", + "name": "Brother PT-P750W", + "model": "PT-P750W", + "backend": "ptouch", + "connection": {}, + "enabled": True, + "paused": False, + "created_at": now, + "updated_at": now, + } + pr = PrinterRead(**payload) + assert pr.slug == "brother-p750w" diff --git a/backend/tests/unit/test_printer_slug.py b/backend/tests/unit/test_printer_slug.py new file mode 100644 index 0000000..85d7521 --- /dev/null +++ b/backend/tests/unit/test_printer_slug.py @@ -0,0 +1,15 @@ +"""Verifiziert dass das Printer-Modell ein `slug`-Feld hat und Default leer ist.""" + +from __future__ import annotations + +from app.models.printer import Printer + + +def test_printer_model_has_slug_field(): + p = Printer(name="Brother PT-P750W", model="PT-P750W", backend="ptouch", slug="brother-p750w") + assert p.slug == "brother-p750w" + + +def test_printer_slug_defaults_to_empty(): + p = Printer(name="X", model="X", backend="mock") + assert p.slug == ""