From 3feae151b7d8b048408e93f99beb56ea714ca7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 10:33:44 +0000 Subject: [PATCH 1/3] feat(integrations): AppLookupService aggregator with Protocol-typed dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes lookup(source_app, identifier) calls to the registered per-app client (Snipe-IT, Grocy, Spoolman) using a Protocol-typed _LookupClient contract. UnknownAppError is kept intentionally distinct from AppLookupNotFoundError — one is a caller configuration mistake, the other is a missing entity. Keyword-only __init__, AVAILABLE_APPS constant, and available_apps property support future clients and API-layer discovery without signature changes. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/lookup_service.py | 73 +++++++++++ .../unit/services/test_lookup_service.py | 120 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 backend/app/services/lookup_service.py create mode 100644 backend/tests/unit/services/test_lookup_service.py diff --git a/backend/app/services/lookup_service.py b/backend/app/services/lookup_service.py new file mode 100644 index 0000000..03393d8 --- /dev/null +++ b/backend/app/services/lookup_service.py @@ -0,0 +1,73 @@ +"""Aggregator that routes lookup requests to the right per-app client. + +The service does not know any client's internals — it just dispatches by +`source_app` to a registered async lookup callable. New apps (e.g. +OpenFoodFacts) plug in by extending the constructor and the `_AppName` +literal. + +UnknownAppError signals a configuration mismatch (the caller asked for an +app that wasn't registered). It deliberately does NOT inherit from +AppLookupNotFoundError — the two failure modes are operationally distinct: + +- UnknownAppError: "you misconfigured the request" +- AppLookupNotFoundError (from any client): "the entity doesn't exist" +""" + +from __future__ import annotations + +from typing import Literal, Protocol + +from app.schemas.label_data import LabelData + +_AppName = Literal["snipeit", "grocy", "spoolman"] + +AVAILABLE_APPS: tuple[_AppName, ...] = ("snipeit", "grocy", "spoolman") + + +class _LookupClient(Protocol): + """Minimal contract every per-app client satisfies. + + SnipeITClient.lookup, GrocyClient.lookup, SpoolmanClient.lookup all + match this shape — `Protocol` lets us depend on the method without + importing concrete classes (avoids a cycle and keeps tests trivial). + """ + + async def lookup(self, identifier: str) -> LabelData: ... + + +class UnknownAppError(Exception): + """Raised when `source_app` does not match any registered client.""" + + +class AppLookupService: + """Route `lookup(source_app, id)` to the right per-app client.""" + + def __init__( + self, + *, + snipeit: _LookupClient, + grocy: _LookupClient, + spoolman: _LookupClient, + ) -> None: + self._clients: dict[str, _LookupClient] = { + "snipeit": snipeit, + "grocy": grocy, + "spoolman": spoolman, + } + + async def lookup(self, source_app: str, identifier: str) -> LabelData: + """Dispatch to `source_app`'s client. + + Raises UnknownAppError if `source_app` is not registered. Any + AppLookupNotFoundError from the underlying client propagates + unchanged so callers can catch it uniformly. + """ + client = self._clients.get(source_app) + if client is None: + raise UnknownAppError(f"Unknown app {source_app!r}. Available: {sorted(self._clients)}") + return await client.lookup(identifier) + + @property + def available_apps(self) -> tuple[str, ...]: + """Return the registered app names in stable order — useful for API discovery.""" + return tuple(sorted(self._clients)) diff --git a/backend/tests/unit/services/test_lookup_service.py b/backend/tests/unit/services/test_lookup_service.py new file mode 100644 index 0000000..3ffd838 --- /dev/null +++ b/backend/tests/unit/services/test_lookup_service.py @@ -0,0 +1,120 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from app.schemas.label_data import LabelData +from app.services.errors import AppLookupNotFoundError +from app.services.lookup_service import ( + AVAILABLE_APPS, + AppLookupService, + UnknownAppError, +) + + +def _make_service( + *, + snipeit: MagicMock | None = None, + grocy: MagicMock | None = None, + spoolman: MagicMock | None = None, +) -> AppLookupService: + """Build a service with MagicMock defaults — tests override what they need.""" + return AppLookupService( + snipeit=snipeit or MagicMock(), + grocy=grocy or MagicMock(), + spoolman=spoolman or MagicMock(), + ) + + +@pytest.mark.asyncio +async def test_lookup_routes_snipeit() -> None: + snipeit = MagicMock() + snipeit.lookup = AsyncMock( + return_value=LabelData( + title="MacBook", + primary_id="ASSET-1", + qr_payload="https://snipe.example/h/1", + source_app="snipeit", + ) + ) + service = _make_service(snipeit=snipeit) + + data = await service.lookup("snipeit", "ASSET-1") + + snipeit.lookup.assert_awaited_once_with("ASSET-1") + assert data.title == "MacBook" + + +@pytest.mark.asyncio +async def test_lookup_routes_grocy() -> None: + grocy = MagicMock() + grocy.lookup = AsyncMock( + return_value=LabelData(title="Milch", primary_id="42", qr_payload="x", source_app="grocy") + ) + service = _make_service(grocy=grocy) + + data = await service.lookup("grocy", "42") + + grocy.lookup.assert_awaited_once_with("42") + assert data.title == "Milch" + + +@pytest.mark.asyncio +async def test_lookup_routes_spoolman() -> None: + spoolman = MagicMock() + spoolman.lookup = AsyncMock( + return_value=LabelData( + title="BambuLab PLA", primary_id="#7", qr_payload="x", source_app="spoolman" + ) + ) + service = _make_service(spoolman=spoolman) + + data = await service.lookup("spoolman", "7") + + spoolman.lookup.assert_awaited_once_with("7") + assert data.title == "BambuLab PLA" + + +@pytest.mark.asyncio +async def test_lookup_unknown_app_raises_unknown_app_error() -> None: + service = _make_service() + + with pytest.raises(UnknownAppError, match="bogus"): + await service.lookup("bogus", "x") + + +@pytest.mark.asyncio +async def test_unknown_app_error_message_lists_available_apps() -> None: + service = _make_service() + + with pytest.raises(UnknownAppError) as excinfo: + await service.lookup("bogus", "x") + + msg = str(excinfo.value) + for app in AVAILABLE_APPS: + assert app in msg, f"Expected {app} in error message, got: {msg}" + + +@pytest.mark.asyncio +async def test_lookup_propagates_app_lookup_not_found_unchanged() -> None: + """AppLookupNotFoundError from a client must propagate — the aggregator does not swallow it.""" + snipeit = MagicMock() + snipeit.lookup = AsyncMock(side_effect=AppLookupNotFoundError("Asset 'X' not found")) + service = _make_service(snipeit=snipeit) + + with pytest.raises(AppLookupNotFoundError, match="X"): + await service.lookup("snipeit", "X") + + +def test_available_apps_constant_matches_registered_clients() -> None: + """The exported AVAILABLE_APPS constant must agree with the actual registry.""" + service = _make_service() + assert set(service.available_apps) == set(AVAILABLE_APPS) + + +def test_unknown_app_error_does_not_inherit_from_app_lookup_not_found() -> None: + """UnknownAppError is a configuration mismatch, NOT an entity-not-found. + + The aggregator's clients raise AppLookupNotFoundError for missing entities. + UnknownAppError is operationally distinct (caller bug, not data state) and + must not be confused with it. + """ + assert not issubclass(UnknownAppError, AppLookupNotFoundError) From 29ba1ad51ba47ed30cb13a947226b1a2f0c2f14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 10:37:40 +0000 Subject: [PATCH 2/3] refactor(lookup-service): derive AVAILABLE_APPS via get_args, precompute available_apps, document Literal limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix A: AVAILABLE_APPS now derived from get_args(_AppName) — eliminates string duplication and drift risk between the Literal and the constant. - Fix B: available_apps converted from @property (re-sorts on every call) to a plain attribute computed once in __init__; _clients is immutable after construction so this is always safe. - Fix C: lookup() docstring extended to clarify that _AppName is for static-analysis tooling only and does not restrict runtime callers; UnknownAppError covers the runtime mismatch case. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/lookup_service.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/app/services/lookup_service.py b/backend/app/services/lookup_service.py index 03393d8..f126fc2 100644 --- a/backend/app/services/lookup_service.py +++ b/backend/app/services/lookup_service.py @@ -15,13 +15,13 @@ from __future__ import annotations -from typing import Literal, Protocol +from typing import Literal, Protocol, get_args from app.schemas.label_data import LabelData _AppName = Literal["snipeit", "grocy", "spoolman"] -AVAILABLE_APPS: tuple[_AppName, ...] = ("snipeit", "grocy", "spoolman") +AVAILABLE_APPS: tuple[_AppName, ...] = get_args(_AppName) class _LookupClient(Protocol): @@ -54,10 +54,17 @@ def __init__( "grocy": grocy, "spoolman": spoolman, } + # Computed once at construction — _clients never mutates after __init__. + self.available_apps: tuple[str, ...] = tuple(sorted(self._clients)) async def lookup(self, source_app: str, identifier: str) -> LabelData: """Dispatch to `source_app`'s client. + `source_app` is validated against the registry at runtime. The + `_AppName` Literal exists for static-analysis tooling only and does + NOT restrict what strings callers may pass — UnknownAppError covers + the runtime mismatch case. + Raises UnknownAppError if `source_app` is not registered. Any AppLookupNotFoundError from the underlying client propagates unchanged so callers can catch it uniformly. @@ -66,8 +73,3 @@ async def lookup(self, source_app: str, identifier: str) -> LabelData: if client is None: raise UnknownAppError(f"Unknown app {source_app!r}. Available: {sorted(self._clients)}") return await client.lookup(identifier) - - @property - def available_apps(self) -> tuple[str, ...]: - """Return the registered app names in stable order — useful for API discovery.""" - return tuple(sorted(self._clients)) From 9d02bf8d88d2b8d3113a65147392465818d3b9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 10:44:39 +0000 Subject: [PATCH 3/3] refactor(lookup-service): tighten dict/tuple types to _AppName Literal + reuse available_apps in error message - _clients dict and available_apps tuple are now typed dict[_AppName, _LookupClient] and tuple[_AppName, ...] respectively; string literals in the dict literal satisfy mypy without cast at construction - cast(_AppName, source_app) at the .get() call site bridges the intentionally wider str parameter (runtime validation) to the narrower key type - UnknownAppError message now uses precomputed self.available_apps instead of re-sorting self._clients on every error path Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/lookup_service.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/app/services/lookup_service.py b/backend/app/services/lookup_service.py index f126fc2..e058c86 100644 --- a/backend/app/services/lookup_service.py +++ b/backend/app/services/lookup_service.py @@ -15,7 +15,7 @@ from __future__ import annotations -from typing import Literal, Protocol, get_args +from typing import Literal, Protocol, cast, get_args from app.schemas.label_data import LabelData @@ -49,13 +49,13 @@ def __init__( grocy: _LookupClient, spoolman: _LookupClient, ) -> None: - self._clients: dict[str, _LookupClient] = { + self._clients: dict[_AppName, _LookupClient] = { "snipeit": snipeit, "grocy": grocy, "spoolman": spoolman, } # Computed once at construction — _clients never mutates after __init__. - self.available_apps: tuple[str, ...] = tuple(sorted(self._clients)) + self.available_apps: tuple[_AppName, ...] = tuple(sorted(self._clients)) async def lookup(self, source_app: str, identifier: str) -> LabelData: """Dispatch to `source_app`'s client. @@ -69,7 +69,9 @@ async def lookup(self, source_app: str, identifier: str) -> LabelData: AppLookupNotFoundError from the underlying client propagates unchanged so callers can catch it uniformly. """ - client = self._clients.get(source_app) + client = self._clients.get(cast(_AppName, source_app)) if client is None: - raise UnknownAppError(f"Unknown app {source_app!r}. Available: {sorted(self._clients)}") + raise UnknownAppError( + f"Unknown app {source_app!r}. Available: {list(self.available_apps)}" + ) return await client.lookup(identifier)