TICKET-006 (backend): OFF normalization, scan/search/refresh, restore-on-rescan

- services/off.py: single normalizer module (kcal/kJ fallback, not-found
  rule); fetch_product/search_off degrade gracefully on upstream errors
  (503/timeout → None / []), never 500; User-Agent + timeouts on all calls
- routers/off.py: thin routes for GET /api/off/product/{barcode},
  GET /api/off/search, POST /api/off/refresh/{food_id} (404 unknown id,
  400 no-barcode)
- services/foods.py: restore-on-rescan (§3.1) — barcode collision on a
  soft-deleted food clears deleted_at + updates row instead of 409;
  GET /api/foods/recent ordered by most-recent daily_log appearance,
  deduped, soft-deleted excluded
- tests: httpx mocked at the boundary (MockTransport, no real OFF);
  48 new tests (normalizer unit + router + restore + recent + error paths)
- 152 passing (was 104)
This commit is contained in:
Craig
2026-07-26 15:46:24 +01:00
parent f8048da9c1
commit 32461b7405
7 changed files with 1095 additions and 46 deletions
+32
View File
@@ -3,6 +3,7 @@
import os
import tempfile
import httpx
import pytest
# Must be set before any app module is imported (engine binds at import time)
@@ -20,3 +21,34 @@ def client():
run_migrations()
with TestClient(app) as c:
yield c
@pytest.fixture
def off_mock(monkeypatch):
"""Replace _default_client() in services.off with a mock client backed
by httpx.MockTransport. Tests assign off_mock.handler to control
responses.
Usage:
def test_foo(client, off_mock):
def handler(request):
return httpx.Response(200, json={...})
off_mock.handler = handler
resp = client.get("/api/off/product/123")
"""
state = _MockState()
def _make_mock_client():
return httpx.Client(
transport=httpx.MockTransport(lambda req: state.handler(req))
)
import services.off as off_mod
monkeypatch.setattr(off_mod, "_default_client", _make_mock_client)
return state
class _MockState:
"""Mutable state so tests can set .handler after fixture injection."""
def __init__(self):
self.handler = None