kiwi/tests/api/test_label_capture.py
pyr0ball 17e62c451f feat: visual label capture for unenriched barcodes (kiwi#79)
When a barcode scan finds no product in FDC/OFF, paid-tier users now see a
"Capture label" offer instead of a dead-end "add manually" prompt.

Backend:
- Migration 036: captured_products local cache table (keyed by barcode,
  UPSERT on conflict so re-capture refreshes rather than errors)
- store.get_captured_product / save_captured_product (with JSON decode for
  ingredient_names and allergens)
- app/services/label_capture.py: wraps cf-core VisionRouter (caption API);
  graceful fallback to zero-confidence mock when stub/error; JSON fence
  stripping; confidence clamped to [0,1]; KIWI_LABEL_CAPTURE_MOCK=1 for tests
- New schemas: LabelCaptureResponse, LabelConfirmRequest, LabelConfirmResponse
- POST /inventory/scan/label-capture — image to extraction (paid+ gate, 403)
- POST /inventory/scan/label-confirm — save confirmed product + optional
  inventory add
- Both scan endpoints now: check captured_products cache before FDC/OFF;
  set needs_visual_capture=True for gap products on paid tier; BarcodeScanResult
  gains needs_visual_capture field
- visual_label_capture feature gate added to tiers.py (paid)

Tests: 42 new tests (service, store/migration, API endpoints) — 367 total passing

Frontend:
- InventoryList.vue: capturePhase state machine (offer => uploading => reviewing)
- Offer card appears after scan gap (calm UX: no urgency, Discard always visible)
- Review form: pre-populated from extraction; amber label highlights for
  unread fields (confidence < 0.7); comma-separated ingredients/allergens
- api.ts: LabelCaptureResult + LabelConfirmRequest types; captureLabelPhoto()
  and confirmLabelCapture() API methods
2026-04-24 17:57:25 -07:00

270 lines
10 KiB
Python

"""
Tests for the visual label capture API endpoints (kiwi#79):
POST /api/v1/inventory/scan/label-capture
POST /api/v1/inventory/scan/label-confirm
GET /api/v1/inventory/scan/text — cache hit + needs_visual_capture flag
"""
import io
import os
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
from app.main import app
from app.cloud_session import get_session
from app.db.session import get_store
client = TestClient(app)
def _session(tier: str = "paid", has_byok: bool = False) -> MagicMock:
m = MagicMock()
m.tier = tier
m.has_byok = has_byok
m.user_id = "test-user"
return m
def _store(**extra_returns) -> MagicMock:
m = MagicMock()
m.get_setting.return_value = None
m.get_captured_product.return_value = None
m.save_captured_product.return_value = {"id": 1, "barcode": "1234567890", "confirmed_by_user": 1}
m.get_or_create_product.return_value = (
{"id": 10, "name": "Test Product", "barcode": "1234567890",
"brand": None, "category": None, "description": None,
"image_url": None, "nutrition_data": {}, "source": "visual_capture",
"created_at": "2026-01-01", "updated_at": "2026-01-01"},
False,
)
m.add_inventory_item.return_value = {
"id": 99, "product_id": 10, "product_name": "Test Product",
"barcode": "1234567890", "category": None,
"quantity": 1.0, "unit": "count", "location": "pantry",
"sublocation": None, "purchase_date": None,
"expiration_date": None, "opened_date": None,
"opened_expiry_date": None, "secondary_state": None,
"secondary_uses": None, "secondary_warning": None,
"secondary_discard_signs": None, "status": "available",
"notes": None, "disposal_reason": None,
"source": "visual_capture", "created_at": "2026-01-01",
"updated_at": "2026-01-01",
}
for k, v in extra_returns.items():
setattr(m, k, v)
return m
@pytest.fixture(autouse=True)
def mock_env(monkeypatch):
monkeypatch.setenv("KIWI_LABEL_CAPTURE_MOCK", "1")
yield
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
# ── /scan/label-capture ───────────────────────────────────────────────────────
class TestLabelCaptureEndpoint:
def setup_method(self):
self.session = _session(tier="paid")
self.store = _store()
app.dependency_overrides[get_session] = lambda: self.session
app.dependency_overrides[get_store] = lambda: self.store
def teardown_method(self):
app.dependency_overrides.clear()
def test_returns_200_for_paid_tier(self):
resp = client.post(
"/api/v1/inventory/scan/label-capture",
data={"barcode": "1234567890"},
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
)
assert resp.status_code == 200
def test_response_contains_barcode(self):
resp = client.post(
"/api/v1/inventory/scan/label-capture",
data={"barcode": "5901234123457"},
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
)
data = resp.json()
assert data["barcode"] == "5901234123457"
def test_response_has_needs_review_field(self):
resp = client.post(
"/api/v1/inventory/scan/label-capture",
data={"barcode": "1234567890"},
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
)
data = resp.json()
assert "needs_review" in data
def test_mock_extraction_has_zero_confidence(self):
resp = client.post(
"/api/v1/inventory/scan/label-capture",
data={"barcode": "1234567890"},
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
)
data = resp.json()
assert data["confidence"] == 0.0
assert data["needs_review"] is True
def test_free_tier_returns_403(self):
app.dependency_overrides[get_session] = lambda: _session(tier="free")
resp = client.post(
"/api/v1/inventory/scan/label-capture",
data={"barcode": "1234567890"},
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
)
assert resp.status_code == 403
def test_local_tier_bypasses_gate(self):
app.dependency_overrides[get_session] = lambda: _session(tier="local")
resp = client.post(
"/api/v1/inventory/scan/label-capture",
data={"barcode": "1234567890"},
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
)
assert resp.status_code == 200
# ── /scan/label-confirm ───────────────────────────────────────────────────────
class TestLabelConfirmEndpoint:
def setup_method(self):
self.session = _session(tier="paid")
self.store = _store()
app.dependency_overrides[get_session] = lambda: self.session
app.dependency_overrides[get_store] = lambda: self.store
def teardown_method(self):
app.dependency_overrides.clear()
def test_returns_200(self):
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
"barcode": "1234567890",
"product_name": "Test Crackers",
"calories": 120.0,
"ingredient_names": ["flour", "salt"],
"allergens": ["wheat"],
"confidence": 0.88,
"auto_add": True,
"location": "pantry",
"quantity": 1.0,
})
assert resp.status_code == 200
def test_ok_true_in_response(self):
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
"barcode": "1234567890",
"auto_add": False,
})
assert resp.json()["ok"] is True
def test_save_captured_product_called(self):
client.post("/api/v1/inventory/scan/label-confirm", json={
"barcode": "1234567890",
"product_name": "Test",
"auto_add": False,
})
self.store.save_captured_product.assert_called_once()
call_kwargs = self.store.save_captured_product.call_args
assert call_kwargs[0][0] == "1234567890"
def test_auto_add_true_creates_inventory_item(self):
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
"barcode": "1234567890",
"auto_add": True,
})
data = resp.json()
assert data["inventory_item_id"] is not None
self.store.add_inventory_item.assert_called_once()
def test_auto_add_false_skips_inventory(self):
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
"barcode": "1234567890",
"auto_add": False,
})
data = resp.json()
assert data["inventory_item_id"] is None
self.store.add_inventory_item.assert_not_called()
def test_free_tier_blocked(self):
app.dependency_overrides[get_session] = lambda: _session(tier="free")
resp = client.post("/api/v1/inventory/scan/label-confirm", json={"barcode": "1234567890"})
assert resp.status_code == 403
# ── /scan/text cache hit + needs_visual_capture ───────────────────────────────
class TestScanTextWithCaptureGating:
def teardown_method(self):
app.dependency_overrides.clear()
def _setup(self, tier: str, cached=None, off_result=None):
store = _store()
store.get_captured_product.return_value = cached
session = _session(tier=tier)
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_store] = lambda: store
return store
def _off_patch(self, result):
"""Patch OpenFoodFactsService.lookup_product at the class level."""
from unittest.mock import AsyncMock, patch
return patch(
"app.services.openfoodfacts.OpenFoodFactsService.lookup_product",
new=AsyncMock(return_value=result),
)
def test_paid_tier_no_product_sets_needs_visual_capture(self):
self._setup(tier="paid")
with self._off_patch(None):
resp = client.post("/api/v1/inventory/scan/text", json={
"barcode": "0000000000000", "location": "pantry",
})
assert resp.status_code == 200
result = resp.json()["results"][0]
assert result["needs_visual_capture"] is True
assert result["needs_manual_entry"] is False
def test_free_tier_no_product_sets_needs_manual_entry(self):
self._setup(tier="free")
with self._off_patch(None):
resp = client.post("/api/v1/inventory/scan/text", json={
"barcode": "0000000000000", "location": "pantry",
})
result = resp.json()["results"][0]
assert result["needs_visual_capture"] is False
assert result["needs_manual_entry"] is True
def test_cache_hit_uses_captured_product(self):
cached = {
"barcode": "9999999999999",
"product_name": "Cached Crackers",
"brand": "TestBrand",
"confirmed_by_user": 1,
"ingredient_names": ["flour"],
"allergens": [],
"calories": 110.0,
"fat_g": None, "saturated_fat_g": None, "carbs_g": None,
"sugar_g": None, "fiber_g": None, "protein_g": None,
"sodium_mg": None, "serving_size_g": None,
}
store = self._setup(tier="paid", cached=cached)
store.get_inventory_item.return_value = store.add_inventory_item.return_value
with self._off_patch(None): # OFF never called when cache hits
resp = client.post("/api/v1/inventory/scan/text", json={
"barcode": "9999999999999", "location": "pantry",
})
assert resp.status_code == 200
result = resp.json()["results"][0]
assert result["added_to_inventory"] is True
assert result["needs_visual_capture"] is False
# OFF was not called (cache resolved it)
# store.get_captured_product was called with the barcode
store.get_captured_product.assert_called_once_with("9999999999999")