diff --git a/README.md b/README.md index 057d85a..3ce7814 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,12 @@ nomnom setup nomnom doctor --json ``` -Run `nomnom setup` in an interactive terminal before the first food log. It probes Open Food -Facts, explains that OFF needs no key, shows the official USDA signup URL, validates the USDA key -with a minimal FoodData Central request, and only then stores it locally. Follow with -`nomnom doctor --json` to verify current provider reachability instead of assuming setup worked. +Run `nomnom setup` in an interactive terminal before the first food log. It independently probes +Open Food Facts product/barcode lookup and full-text resolution, explains that OFF needs no key, +shows the official USDA signup URL, validates the USDA key with a minimal FoodData Central request, +and only then stores it locally. Follow with `nomnom doctor --json` to verify current provider +readiness instead of assuming setup worked. OFF product reachability does not imply that full-text +resolution is ready. USDA credentials are local user configuration, never repository or database content. The default path is `$XDG_CONFIG_HOME/nomnomcli/config.toml` or `~/.config/nomnomcli/config.toml`, written with @@ -73,13 +75,18 @@ Successful API results are cached in the user's database, so the same food can r later. Existing v0.2 cache records, logs, and recipes are preserved when v0.3 opens the database. `nomnom search QUERY` searches this user cache; it is not a packaged food catalog. -Open Food Facts search goes directly through its official REST endpoint, -`https://api.openfoodfacts.org/api/v2/search`, with a descriptive nomnomcli User-Agent. Direct -`requests` calls are intentional: the official REST contract is small and transparent, while a -third-party OFF SDK would add an unofficial dependency without adding an authenticated workflow or -schema capability. nomnom applies bounded backoff to HTTP 429 and 5xx responses, safely honors a -numeric `Retry-After`, and reports a typed retryable `openfoodfacts_unavailable` error after retries -are exhausted. It never silently falls back to the unstable `world` search host. +Open Food Facts free-text search goes directly through the official legacy v1 endpoint, +`https://world.openfoodfacts.org/cgi/search.pl`, with `search_terms`, `search_simple=1`, +`action=process`, `json=1`, `page_size`, the supported response `fields`, and a descriptive +nomnomcli User-Agent. API v2 search is structured/filter-only: nomnom never sends free-text +`search_terms` to v2 and never falls back to unfiltered v2 catalog rows. Direct `requests` calls are +intentional because this small provider contract remains explicit and replay-testable. + +Both OFF capabilities use bounded backoff for HTTP 429 and 5xx responses and safely honor a +numeric, bounded `Retry-After`. If v1 remains unavailable after retries, free-text resolution raises +the typed retryable error `openfoodfacts_unavailable`; it does not silently change endpoint or +return unrelated products. Product normalization and the token/category confidence checks still +apply to every v1 candidate. ### Enable USDA fallback @@ -194,8 +201,15 @@ an `error` object and exit with status 2. Important codes include: - `alias_target_not_found`: add/resolve the exact cached target or remove the stale alias. - `openfoodfacts_unavailable` / `usda_unavailable`: retry later or use a manual label. -Provider-unavailable errors include a `retryable` boolean. `nomnom doctor --json` always emits only -provider `configured`/`reachable` state and USDA `key_source`; it never includes credential values. +Provider-unavailable errors include a `retryable` boolean. In `nomnom doctor --json`, OFF reports: + +- `product_lookup_reachable`: the v2 product-by-barcode endpoint answered after bounded retries; + this says nothing about free-text search. +- `full_text_search_ready`: the same v1 CGI capability used by runtime free-text resolution + answered with a valid product-list payload; when false, OFF free-text resolution is unavailable. + +`configured` means OFF needs no credential. USDA retains `configured`, `reachable`, and +`key_source`. Doctor never includes credential values. Successful logs are stored immediately. Agents should show the returned names, grams, confidence, alternatives, and assumptions before treating the resolution as confirmed. diff --git a/docs/plans.md b/docs/plans.md index 866bd37..1954287 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -1,5 +1,76 @@ # Plans +## Issue #17 Source +- Task: Fix the Open Food Facts full-text provider contract without live-test traffic. +- Canonical input: GitHub issue #17 and the user's required behavior. +- Repo context: `nomnomcli/off.py`, provider onboarding/doctor output, focused tests, README, and agent skill. +- Last updated: 2026-07-21 + +## Issue #17 Assumptions +- OFF's legacy production full-text endpoint is `https://world.openfoodfacts.org/cgi/search.pl`. +- Product/barcode reachability and full-text readiness are independent capabilities and must be reported separately. +- Existing user-cache and USDA fallback behavior remains unchanged; OFF never substitutes v2 catalog rows for failed full-text search. + +## Issue #17 Milestone Order +| ID | Title | Depends on | Status | +| --- | --- | --- | --- | +| M9 | Add failing OFF v1 and readiness contract tests | M8 | [x] | +| M10 | Implement v1-only full-text and split probes | M9 | [x] | +| M11 | Document status semantics and validate | M10 | [x] | +| M12 | Audit and commit scoped changes | M11 | [x] | + +## M9. Add failing OFF v1 and readiness contract tests `[x]` +### Goal +- Capture endpoint, parameter, semantic, retry, no-fallback, confidence, and doctor contracts before implementation. + +### Validation +```sh +pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py +``` + +### Stop-and-Fix Rule +- Record the expected RED failures before modifying runtime code. + +## M10. Implement v1-only full-text and split probes `[x]` +### Goal +- Free text uses only the capability-probed v1 CGI endpoint; v2 remains product/barcode-only. + +### Validation +```sh +pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py +``` + +### Stop-and-Fix Rule +- Never add a v2 free-text fallback or live-network test to make a contract test pass. + +## M11. Document status semantics and validate `[x]` +### Goal +- README, setup output, and `skill/SKILL.md` explain product reachability versus full-text resolution readiness exactly. + +### Validation +```sh +pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py tests/test_install.py +pytest -q +ruff check . +``` + +### Stop-and-Fix Rule +- Repair any focused, full-suite, or lint regression before committing. + +## M12. Audit and commit scoped changes `[x]` +### Goal +- Commit only coherent issue #17 code, tests, docs, and execution records before the separate push/PR step. + +### Validation +```sh +git diff --check +git status --short +git diff --stat +``` + +### Stop-and-Fix Rule +- Do not commit until the diff is scoped and every acceptance gate passes. + ## v0.2 Source - Task: Ship nomnomcli v0.2 resolving GitHub issues #1, #2, and #3. - Canonical input: User-provided release brief for size parsing, Open Food Facts, and data quality. diff --git a/docs/status.md b/docs/status.md index 34c9106..e55d3f8 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,12 +1,13 @@ # Status ## Snapshot -- Current phase: v0.2 complete +- Current phase: issue #17 complete - Plan file: `docs/plans.md` - Status: green -- Last updated: 2026-07-19 +- Last updated: 2026-07-21 ## Done +- Fixed issue #17 with OFF v1-only full-text search, independent product/full-text probes, typed bounded 503 handling, deterministic contract coverage, and updated provider docs. - Built the complete package, 431-food database, 258-entry Russian synonym layer, CLI, recipes, installer, skill, docs, CI, and tests. - Passed 30 pytest tests and Ruff with no issues. - Passed the exact mixed-Russian log/stats flow, fixture recipe import/log, and installer dry-run. @@ -19,14 +20,17 @@ - None. ## Next -- No push or PR was requested; the local conventional release commit is the handoff point. +- Push `feat/off-fulltext-contract` and open the issue #17 pull request after independent verification. ## Decisions Made +- Route all OFF free text to legacy v1 `/cgi/search.pl`; never pass `search_terms` to v2. +- Report OFF product/barcode reachability separately from full-text resolution readiness. - Use stdlib `argparse` — keeps runtime dependencies to `requests` only. - Use SQLite for shipped foods and per-user mutable state — matches the offline-first contract. - Reuse `scripts/build_mini_db.py --update-existing` for deterministic offline v0.2 data repair without shrinking the tracked USDA corpus. ## Assumptions In Force +- Issue #17 tests use only mocked/replay transports and never live OFF traffic. - Agent confirmation is an operating pattern, not a pending database transaction in v0.1. - Named brands are never resolved to bundled generic foods; manual cache entries are the offline escape hatch. - Descriptor weights are estimates that must be surfaced as assumptions in JSON and text. @@ -52,6 +56,8 @@ ruff check . | 2026-07-19 | M6 | OFF client, food repository, cache migration, CLI add, tests | mocked OFF success/503/network/malformed/ambiguity/cache runs | pass | M7 | | 2026-07-19 | M7 | data overrides, offline seed, bundled SQLite, builder, quality tests | `build_mini_db.py --update-existing`; deterministic hash audit | 431 rows; pass | M8 | | 2026-07-19 | M8 | README, skill, version, planning docs | `pytest -q`; `ruff check .`; exact isolated smoke; diff/junk audit | 72 pass; clean | commit | +| 2026-07-21 | M9 | OFF, food confidence, doctor/setup contract tests | `PYTHONPATH=. pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py`; focused setup test | RED: missing product probe; RED: missing status explanation | M10 | +| 2026-07-21 | M10–M11 | OFF client, onboarding/CLI, README, skill, tests | focused pytest; full pytest; full Ruff | 62 pass; 126 pass; clean | M12 | ## Smoke / Demo Checklist - [x] Russian mixed-item log works and persists. @@ -63,3 +69,5 @@ ruff check . - [x] OFF success/failure paths are covered without live traffic. - [x] Bundled database passes v0.2 quality tests. - [x] Version and documentation report 0.2.0 behavior. +- [x] OFF free text uses v1 with no unfiltered v2 fallback. +- [x] Doctor and setup distinguish product/barcode reachability from full-text readiness. diff --git a/docs/test-plan.md b/docs/test-plan.md index 776cc64..4db5204 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -1,5 +1,32 @@ # Test Plan +## Issue #17 Source +- Task: Validate the OFF full-text provider contract from GitHub issue #17. +- Plan file: `docs/plans.md` +- Status file: `docs/status.md` +- Last updated: 2026-07-21 + +## Issue #17 Validation Scope +- In scope: v1 CGI URL and parameters, query-specific mocked results, no v2 free-text fallback, bounded 503/429 retry including `Retry-After`, split OFF doctor readiness, existing confidence filtering, README, setup output, and agent skill. +- Out of scope: live OFF/USDA traffic, bundled food data, aliases, nutrition facts, or unrelated resolver behavior. + +## Issue #17 Fixtures and Network Rules +- Every HTTP response is injected or monkeypatched; tests must be deterministic and replayable offline. +- Distinct terms map to distinct mocked v1 payloads so forwarding semantics are observable. + +## Issue #17 Acceptance Gates +- [x] Focused contract tests observed RED before runtime implementation. +- [x] `pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py tests/test_install.py` — 62 passed. +- [x] `pytest -q` — 126 passed. +- [x] `ruff check .` — clean. +- [x] `git diff --check` — clean. +- [x] Scoped conventional commit created locally; no push or PR. + +## Issue #17 Negative / Edge Cases +- v1 503 exhausts bounded retries and raises retryable `openfoodfacts_unavailable` without any v2 request. +- A valid but unrelated v1 candidate remains rejected as `off_low_confidence` and is not cached. +- Product/barcode HTTP reachability cannot make doctor report full-text resolution ready. + ## v0.2 Source - Task: Validate nomnomcli v0.2 issues #1, #2, and #3. - Plan file: `docs/plans.md` diff --git a/nomnomcli/cli.py b/nomnomcli/cli.py index 876b645..9224a52 100644 --- a/nomnomcli/cli.py +++ b/nomnomcli/cli.py @@ -123,12 +123,19 @@ def _run(args: argparse.Namespace) -> int: print("USDA FoodData Central: free API key; generic/raw foods and fallback.") print("USDA signup: https://fdc.nal.usda.gov/api-key-signup.html") result = setup_providers(interactive=sys.stdin.isatty()) - off_status = ( + off_product_status = ( "reachable" - if result["providers"]["openfoodfacts"]["reachable"] + if result["providers"]["openfoodfacts"]["product_lookup_reachable"] else "unreachable" ) - print(f"Open Food Facts (no key): {off_status}") + off_search_status = ( + "ready" + if result["providers"]["openfoodfacts"]["full_text_search_ready"] + else "unavailable" + ) + print(f"Open Food Facts product/barcode lookup (no key): {off_product_status}") + print(f"Open Food Facts full-text resolution: {off_search_status}") + print("Product reachability does not imply full-text readiness.") print( "USDA: reachable; key source " f"{result['providers']['usda']['key_source']}" @@ -143,6 +150,16 @@ def _run(args: argparse.Namespace) -> int: print(_json_output(result)) else: for provider, status in result["providers"].items(): + if provider == "openfoodfacts": + product_state = ( + "reachable" if status["product_lookup_reachable"] else "unreachable" + ) + search_state = ( + "ready" if status["full_text_search_ready"] else "unavailable" + ) + print(f"openfoodfacts product/barcode lookup: {product_state}") + print(f"openfoodfacts full-text resolution: {search_state}") + continue state = "reachable" if status["reachable"] else "unreachable" print(f"{provider}: {state}") return 0 diff --git a/nomnomcli/off.py b/nomnomcli/off.py index e1ef9fa..7276e9a 100644 --- a/nomnomcli/off.py +++ b/nomnomcli/off.py @@ -12,7 +12,8 @@ from nomnomcli.models import Food from nomnomcli.providers import RetryPolicy, request_with_retry -OFF_SEARCH_URL = "https://api.openfoodfacts.org/api/v2/search" +OFF_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl" +OFF_PRODUCT_PROBE_URL = "https://api.openfoodfacts.org/api/v2/product/0" OFF_FIELDS = "product_name,brands,nutriments,code,serving_size,categories,categories_tags" @@ -93,6 +94,14 @@ def __init__( self.retry_policy = retry_policy or RetryPolicy() self.sleep = sleep + def _headers(self) -> dict[str, str]: + return { + "User-Agent": ( + f"nomnomcli/{__version__} " + "(+https://github.com/maxjustships/nomnomcli)" + ) + } + def _get_payload(self, query: str, page_size: int) -> dict: details = { "food": query, @@ -104,22 +113,20 @@ def _get_payload(self, query: str, page_size: int) -> dict: response = request_with_retry( provider="openfoodfacts", code="openfoodfacts_unavailable", - message="Open Food Facts lookup is unavailable", + message="Open Food Facts full-text search is unavailable", request_get=self._request_get or requests.get, url=OFF_SEARCH_URL, request_kwargs={ "params": { "search_terms": query, + "search_simple": 1, + "action": "process", + "json": 1, "fields": OFF_FIELDS, "page_size": page_size, }, "timeout": 10, - "headers": { - "User-Agent": ( - f"nomnomcli/{__version__} " - "(+https://github.com/maxjustships/nomnomcli)" - ) - }, + "headers": self._headers(), }, details=details, retry_policy=self.retry_policy, @@ -132,7 +139,7 @@ def _get_payload(self, query: str, page_size: int) -> dict: raise ProviderUnavailableError( "openfoodfacts", "openfoodfacts_unavailable", - "Open Food Facts lookup is unavailable", + "Open Food Facts full-text search is unavailable", retryable=False, details={**details, "reason": "http_error", "status": status, "attempts": 1}, ) from exc @@ -153,7 +160,25 @@ def _get_payload(self, query: str, page_size: int) -> dict: return payload def probe(self) -> bool: - self._get_payload("a", 1) + self._get_payload("nomnom", 1) + return True + + def probe_product(self) -> bool: + request_with_retry( + provider="openfoodfacts", + code="openfoodfacts_unavailable", + message="Open Food Facts product lookup is unavailable", + request_get=self._request_get or requests.get, + url=OFF_PRODUCT_PROBE_URL, + request_kwargs={ + "params": {"fields": "code"}, + "timeout": 10, + "headers": self._headers(), + }, + details={"capability": "product_lookup"}, + retry_policy=self.retry_policy, + sleep=self.sleep, + ) return True def search(self, query: str, page_size: int = 5) -> list[Food]: diff --git a/nomnomcli/onboarding.py b/nomnomcli/onboarding.py index 3b0d7f7..6247f3a 100644 --- a/nomnomcli/onboarding.py +++ b/nomnomcli/onboarding.py @@ -17,6 +17,14 @@ def _reachable(probe: Callable[[], bool]) -> bool: return False +def _off_status(off: OpenFoodFactsClient) -> dict: + return { + "configured": True, + "product_lookup_reachable": _reachable(off.probe_product), + "full_text_search_ready": _reachable(off.probe), + } + + def doctor_report( *, config: ProviderConfig | None = None, @@ -32,7 +40,7 @@ def doctor_report( credential = None return { "providers": { - "openfoodfacts": {"configured": True, "reachable": _reachable(off.probe)}, + "openfoodfacts": _off_status(off), "usda": { "configured": credential is not None, "reachable": ( @@ -57,13 +65,13 @@ def setup_providers( provider_config = config or ProviderConfig() off = off_client or OpenFoodFactsClient() usda = usda_client or USDAClient() - off_reachable = _reachable(off.probe) + off_status = _off_status(off) credential = provider_config.usda_credential() if credential is not None: usda.probe(credential.value) return { "providers": { - "openfoodfacts": {"configured": True, "reachable": off_reachable}, + "openfoodfacts": off_status, "usda": { "configured": True, "reachable": True, @@ -94,7 +102,7 @@ def setup_providers( path = provider_config.store_usda_key(api_key) return { "providers": { - "openfoodfacts": {"configured": True, "reachable": off_reachable}, + "openfoodfacts": off_status, "usda": {"configured": True, "reachable": True, "key_source": "user_config"}, }, "config_path": str(path), diff --git a/skill/SKILL.md b/skill/SKILL.md index 5ab9c68..45798f1 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -107,13 +107,22 @@ human grams always override provider serving data, including per-piece input. ## Provider contract -OFF search intentionally uses direct `requests` calls to the official REST endpoint -`https://api.openfoodfacts.org/api/v2/search` with a descriptive User-Agent. No -third-party OFF SDK or alternate `world` search host is used. Retryable 429/5xx -failures use bounded backoff; never implement a silent provider/cache fallback. - -Use `nomnom doctor --json` when diagnosing resolution. Report only -`configured`, `reachable`, and USDA `key_source`; never expose a credential value. +OFF free-text search intentionally uses direct `requests` calls to the official +legacy v1 endpoint `https://world.openfoodfacts.org/cgi/search.pl` with +`search_terms`, `search_simple=1`, `action=process`, `json=1`, `page_size`, +supported response fields, and a descriptive User-Agent. API v2 is only for +structured filters or product/barcode data: never send it free-text +`search_terms`, and never use its unfiltered rows as fallback results. Retryable +429/5xx failures use bounded backoff, including bounded numeric `Retry-After`; +exhausted v1 failures are retryable `openfoodfacts_unavailable` errors. + +Use `nomnom doctor --json` when diagnosing resolution. OFF +`product_lookup_reachable` means only that the v2 product-by-barcode endpoint +answered. OFF `full_text_search_ready` means the same v1 CGI capability used by +free-text resolution returned a valid product-list payload. Product reachability +must never be presented as full-text readiness. `configured` means OFF needs no +credential; USDA retains `configured`, `reachable`, and `key_source`. Never expose +a credential value. ## Stats diff --git a/tests/test_cli.py b/tests/test_cli.py index feb561c..9d79239 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -291,7 +291,11 @@ def test_cli_leading_piece_count_with_explicit_grams_is_114g( def test_cli_doctor_json_contract(monkeypatch, capsys): expected = { "providers": { - "openfoodfacts": {"configured": True, "reachable": True}, + "openfoodfacts": { + "configured": True, + "product_lookup_reachable": True, + "full_text_search_ready": False, + }, "usda": { "configured": False, "reachable": False, @@ -329,6 +333,37 @@ def setup(*, interactive): assert json.loads(captured.err)["error"]["code"] == "setup_requires_interactive" +def test_cli_setup_explains_independent_off_statuses(monkeypatch, capsys): + class Interactive: + def isatty(self): + return True + + monkeypatch.setattr("nomnomcli.cli.sys.stdin", Interactive()) + monkeypatch.setattr( + "nomnomcli.cli.setup_providers", + lambda *, interactive: { + "providers": { + "openfoodfacts": { + "configured": True, + "product_lookup_reachable": True, + "full_text_search_ready": False, + }, + "usda": { + "configured": True, + "reachable": True, + "key_source": "environment", + }, + } + }, + ) + + assert main(["setup"]) == 0 + output = capsys.readouterr().out + assert "product/barcode lookup (no key): reachable" in output + assert "full-text resolution: unavailable" in output + assert "Product reachability does not imply full-text readiness." in output + + def test_cli_off_alternatives_are_additive_json(user_db, monkeypatch, capsys): monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) matches = [ diff --git a/tests/test_config.py b/tests/test_config.py index 67f3d99..1928a42 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,6 +11,9 @@ class HealthyOFF: + def probe_product(self): + return True + def probe(self): return True @@ -77,6 +80,11 @@ def test_interactive_setup_validates_before_secure_write(tmp_path): "reachable": True, "key_source": "user_config", } + assert result["providers"]["openfoodfacts"] == { + "configured": True, + "product_lookup_reachable": True, + "full_text_search_ready": True, + } assert usda.keys == ["new-placeholder"] assert stat.S_IMODE(path.stat().st_mode) == 0o600 @@ -149,7 +157,11 @@ def test_doctor_report_is_deterministic_and_never_contains_key(tmp_path): assert report == { "providers": { - "openfoodfacts": {"configured": True, "reachable": True}, + "openfoodfacts": { + "configured": True, + "product_lookup_reachable": True, + "full_text_search_ready": True, + }, "usda": { "configured": True, "reachable": True, @@ -158,3 +170,29 @@ def test_doctor_report_is_deterministic_and_never_contains_key(tmp_path): } } assert "never-print-placeholder" not in json.dumps(report) + + +def test_doctor_distinguishes_off_product_reachability_from_full_text_readiness( + tmp_path, +): + class ProductOnlyOFF: + def probe_product(self): + return True + + def probe(self): + raise NomnomError( + "openfoodfacts_unavailable", + "Open Food Facts full-text search is unavailable", + ) + + report = doctor_report( + config=ProviderConfig(environ={}, config_path=tmp_path / "config.toml"), + off_client=ProductOnlyOFF(), + usda_client=HealthyUSDA(), + ) + + assert report["providers"]["openfoodfacts"] == { + "configured": True, + "product_lookup_reachable": True, + "full_text_search_ready": False, + } diff --git a/tests/test_foods.py b/tests/test_foods.py index 1b6643f..44bf6aa 100644 --- a/tests/test_foods.py +++ b/tests/test_foods.py @@ -68,8 +68,14 @@ def raise_for_status(self): def json(self): return {"products": [food_fixtures["off"]["cheese"]]} + urls = [] + + def get(url, *args, **kwargs): + urls.append(url) + return Response() + monkeypatch.delenv("NOMNOM_USDA_KEY", raising=False) - monkeypatch.setattr(requests, "get", lambda *args, **kwargs: Response()) + monkeypatch.setattr(requests, "get", get) with pytest.raises(NomnomError) as caught: repository.resolve("кедровые орехи") @@ -77,6 +83,7 @@ def json(self): assert caught.value.code == "off_low_confidence" assert caught.value.details["candidate"]["name"] == "Cheese — Wrong Match" assert caught.value.details["alternatives"] == [] + assert urls == ["https://world.openfoodfacts.org/cgi/search.pl"] assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 diff --git a/tests/test_off.py b/tests/test_off.py index 70338a3..0669098 100644 --- a/tests/test_off.py +++ b/tests/test_off.py @@ -4,7 +4,7 @@ import requests from nomnomcli.errors import NomnomError, ProviderUnavailableError -from nomnomcli.off import OFF_SEARCH_URL, OpenFoodFactsClient +from nomnomcli.off import OFF_PRODUCT_PROBE_URL, OFF_SEARCH_URL, OpenFoodFactsClient from nomnomcli.providers import RetryPolicy @@ -40,7 +40,7 @@ def product(name="Whole Grain Bread", brand="Acme", code="0123456789012", kcal=2 } -def test_off_v2_search_normalizes_product(monkeypatch): +def test_off_v1_full_text_search_uses_official_contract(monkeypatch): captured = {} def fake_get(url, **kwargs): @@ -50,12 +50,14 @@ def fake_get(url, **kwargs): monkeypatch.setattr(requests, "get", fake_get) foods = OpenFoodFactsClient().search("Acme bread", page_size=3) - assert OFF_SEARCH_URL == "https://api.openfoodfacts.org/api/v2/search" - assert "world.openfoodfacts.org" not in captured["url"] + assert OFF_SEARCH_URL == "https://world.openfoodfacts.org/cgi/search.pl" assert captured == { "url": OFF_SEARCH_URL, "params": { "search_terms": "Acme bread", + "search_simple": 1, + "action": "process", + "json": 1, "fields": ( "product_name,brands,nutriments,code,serving_size," "categories,categories_tags" @@ -73,6 +75,72 @@ def fake_get(url, **kwargs): assert (foods[0].kcal, foods[0].protein, foods[0].fat, foods[0].carbs) == (250, 9, 4, 45) +def test_off_v1_distinct_terms_receive_distinct_results(): + requested_terms = [] + + def replay_get(url, **kwargs): + assert url == OFF_SEARCH_URL + term = kwargs["params"]["search_terms"] + requested_terms.append(term) + return Response({"products": [product(name=f"Result for {term}")]}) + + client = OpenFoodFactsClient(request_get=replay_get) + + soy = client.search("soy protein isolate") + peanuts = client.search("peanuts") + + assert requested_terms == ["soy protein isolate", "peanuts"] + assert soy[0].name == "Result for soy protein isolate — Acme" + assert peanuts[0].name == "Result for peanuts — Acme" + assert soy != peanuts + + +def test_off_probe_exercises_same_v1_full_text_capability(): + calls = [] + + def get(url, **kwargs): + calls.append((url, kwargs)) + return Response({"products": []}) + + assert OpenFoodFactsClient(request_get=get).probe() is True + assert len(calls) == 1 + assert calls[0][0] == OFF_SEARCH_URL + assert { + "search_terms": "nomnom", + "search_simple": 1, + "action": "process", + "json": 1, + "page_size": 1, + }.items() <= calls[0][1]["params"].items() + assert "fields" in calls[0][1]["params"] + assert "/api/v2/search" not in calls[0][0] + + +def test_off_product_probe_uses_v2_barcode_endpoint_without_free_text(): + calls = [] + + def get(url, **kwargs): + calls.append((url, kwargs)) + return Response({"status": 0}) + + assert OpenFoodFactsClient(request_get=get).probe_product() is True + assert calls == [ + ( + OFF_PRODUCT_PROBE_URL, + { + "params": {"fields": "code"}, + "timeout": 10, + "headers": { + "User-Agent": ( + "nomnomcli/0.3.0 (+https://github.com/maxjustships/nomnomcli)" + ) + }, + }, + ) + ] + assert "search_terms" not in calls[0][1]["params"] + + def test_off_rejects_candidate_with_nonpositive_core_nutrient(monkeypatch): candidate = product() candidate["nutriments"]["fat_100g"] = 0 @@ -86,7 +154,13 @@ def test_off_rejects_candidate_with_nonpositive_core_nutrient(monkeypatch): def test_off_http_503_is_clear(monkeypatch): - monkeypatch.setattr(requests, "get", lambda *args, **kwargs: Response(status_code=503)) + urls = [] + + def unavailable(url, **kwargs): + urls.append(url) + return Response(status_code=503) + + monkeypatch.setattr(requests, "get", unavailable) with pytest.raises(ProviderUnavailableError) as caught: OpenFoodFactsClient(sleep=lambda _: None).search("Acme bread") assert caught.value.code == "openfoodfacts_unavailable" @@ -94,6 +168,8 @@ def test_off_http_503_is_clear(monkeypatch): assert caught.value.details["attempts"] == 3 assert caught.value.details["retryable"] is True assert "nomnom add" in caught.value.details["offline_escape"] + assert urls == [OFF_SEARCH_URL, OFF_SEARCH_URL, OFF_SEARCH_URL] + assert all("/api/v2/search" not in url for url in urls) def test_off_network_error_is_clear(monkeypatch):