Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions backend/app/services/lookup_service.py
Original file line number Diff line number Diff line change
@@ -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)
120 changes: 120 additions & 0 deletions backend/tests/unit/services/test_lookup_service.py
Original file line number Diff line number Diff line change
@@ -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)
Loading