diff --git a/backend/app/services/lookup_service.py b/backend/app/services/lookup_service.py new file mode 100644 index 0000000..e058c86 --- /dev/null +++ b/backend/app/services/lookup_service.py @@ -0,0 +1,77 @@ +"""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, cast, get_args + +from app.schemas.label_data import LabelData + +_AppName = Literal["snipeit", "grocy", "spoolman"] + +AVAILABLE_APPS: tuple[_AppName, ...] = get_args(_AppName) + + +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[_AppName, _LookupClient] = { + "snipeit": snipeit, + "grocy": grocy, + "spoolman": spoolman, + } + # Computed once at construction — _clients never mutates after __init__. + 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. + + `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. + """ + client = self._clients.get(cast(_AppName, source_app)) + if client is None: + raise UnknownAppError( + f"Unknown app {source_app!r}. Available: {list(self.available_apps)}" + ) + return await client.lookup(identifier) 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)