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
31 changes: 23 additions & 8 deletions backend/app/services/open_food_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,18 @@ def _repair_common_mojibake(text: str) -> str:
return text


def _to_float(value: Any) -> float:
def _to_float(value: Any) -> float | None:
if value is None:
return 0.0
return None
try:
result = float(value)
if not math.isfinite(result):
return 0.0
return None
if result < 0:
return None
return round(result, 2)
except (TypeError, ValueError):
return 0.0
return None


def _to_optional_text(value: Any) -> str | None:
Expand Down Expand Up @@ -135,13 +137,26 @@ async def search_food_products(query: str, page_size: int = 10) -> list[FoodSear
continue

nutriments = product.get("nutriments") or {}
nutrition = {
"calories": _to_float(nutriments.get("energy-kcal_100g")),
"protein": _to_float(nutriments.get("proteins_100g")),
"fat": _to_float(nutriments.get("fat_100g")),
"carbohydrates": _to_float(nutriments.get("carbohydrates_100g")),
}

# A missing value is not the same as a measured zero. Incomplete
# records are excluded from the loggable search results so CalorieApp
# cannot silently turn unknown nutrition into a misleading 0.0 value.
if any(value is None for value in nutrition.values()):
continue

results.append(
FoodSearchResult(
product_name=product_name,
calories=_to_float(nutriments.get("energy-kcal_100g")),
protein=_to_float(nutriments.get("proteins_100g")),
fat=_to_float(nutriments.get("fat_100g")),
carbohydrates=_to_float(nutriments.get("carbohydrates_100g")),
calories=nutrition["calories"],
protein=nutrition["protein"],
fat=nutrition["fat"],
carbohydrates=nutrition["carbohydrates"],
image_url=_extract_image_url(product),
barcode=_to_optional_text(product.get("code")),
brand=_extract_brand(product),
Expand Down
43 changes: 40 additions & 3 deletions backend/tests/test_open_food_facts_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@
)


@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan"), "Infinity", "NaN"])
def test_to_float_rejects_non_finite_upstream_values(value: object) -> None:
@pytest.mark.parametrize(
"value",
[None, float("inf"), float("-inf"), float("nan"), "Infinity", "NaN", -1],
)
def test_to_float_marks_missing_or_invalid_upstream_values_as_unknown(value: object) -> None:
assert _to_float(value) is None


@pytest.mark.parametrize("value", [0, "0", 12.345])
def test_to_float_preserves_real_finite_non_negative_values(value: object) -> None:
result = _to_float(value)
assert result == 0.0
assert result is not None
assert math.isfinite(result)


Expand All @@ -30,6 +38,35 @@ def test_extract_nutri_score_only_returns_supported_grades(
assert _extract_nutri_score({"nutriscore_grade": value}) == expected


@patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock)
def test_search_omits_products_with_unknown_nutrition(primary: AsyncMock) -> None:
primary.return_value = {
"products": [
{
"product_name": "Incomplete oats",
"nutriments": {
"energy-kcal_100g": 375,
"proteins_100g": 13,
"fat_100g": 7,
},
},
{
"product_name": "Complete oats",
"nutriments": {
"energy-kcal_100g": 375,
"proteins_100g": 13,
"fat_100g": 7,
"carbohydrates_100g": 60,
},
},
]
}

results = asyncio.run(search_food_products("oats"))

assert [result.product_name for result in results] == ["Complete oats"]


@patch("app.services.open_food_facts._fetch_fallback", new_callable=AsyncMock)
@patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock)
def test_expected_fallback_failure_becomes_upstream_http_error(
Expand Down
46 changes: 34 additions & 12 deletions frontend/components/FoodSearchPlaceholder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ type PortionOption = "whole" | "half" | "quarter" | "custom";
const SIGN_IN_REQUIRED_LOG_MESSAGE =
"Your session has expired or you are not signed in. Please sign in again to manage food logs.";

function toNumber(value: unknown): number {
if (typeof value !== "number" || Number.isNaN(value)) {
return 0;
function toNumber(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return null;
}
return value;
}
Expand All @@ -47,21 +47,34 @@ function toOptionalNumber(value: unknown): number | undefined {
return value;
}

function normalizeFoodItem(value: unknown): FoodSearchItem {
function normalizeFoodItem(value: unknown): FoodSearchItem | null {
const raw = (value ?? {}) as Record<string, unknown>;
const productName =
typeof raw.product_name === "string" && raw.product_name.trim().length > 0
? raw.product_name
: "Unknown food";
const calories = toNumber(raw.calories);
const protein = toNumber(raw.protein);
const fat = toNumber(raw.fat);
const carbohydrates = toNumber(raw.carbohydrates);

if (
calories === null ||
protein === null ||
fat === null ||
carbohydrates === null
) {
return null;
}

return {
id: toOptionalNumber(raw.id),
created_at: toOptionalText(raw.created_at),
product_name: productName,
calories: toNumber(raw.calories),
protein: toNumber(raw.protein),
fat: toNumber(raw.fat),
carbohydrates: toNumber(raw.carbohydrates),
calories,
protein,
fat,
carbohydrates,
portion_percentage: toOptionalNumber(raw.portion_percentage),
image_url: toOptionalText(raw.image_url),
barcode: toOptionalText(raw.barcode),
Expand All @@ -71,6 +84,12 @@ function normalizeFoodItem(value: unknown): FoodSearchItem {
};
}

function normalizeFoodItems(values: unknown[]): FoodSearchItem[] {
return values
.map(normalizeFoodItem)
.filter((item): item is FoodSearchItem => item !== null);
}

function getPortionPercentage(option: PortionOption, customValue: string): number | null {
if (option === "whole") {
return 100;
Expand Down Expand Up @@ -300,7 +319,7 @@ export function FoodSearchPlaceholder() {
if (requestId !== logsRequestIdRef.current) {
return;
}
setLogs((data ?? []).map(normalizeFoodItem));
setLogs(normalizeFoodItems(data ?? []));
setLogError(null);
} catch (requestError) {
if (requestId === logsRequestIdRef.current) {
Expand Down Expand Up @@ -393,7 +412,7 @@ export function FoodSearchPlaceholder() {
if (requestId !== searchRequestIdRef.current) {
return;
}
setResults((data.results ?? []).map(normalizeFoodItem));
setResults(normalizeFoodItems(data.results ?? []));
} catch (requestError) {
if (!controller.signal.aborted && requestId === searchRequestIdRef.current) {
setResults([]);
Expand Down Expand Up @@ -565,6 +584,9 @@ export function FoodSearchPlaceholder() {
<p className="mt-1 text-sm text-brand-secondary/80">
Explore product nutrition data provided by Open Food Facts.
</p>
<p className="mt-1 text-xs text-brand-secondary/70">
Only records with complete calorie, protein, fat, and carbohydrate values are shown.
</p>

<SearchBar
query={query}
Expand All @@ -591,8 +613,8 @@ export function FoodSearchPlaceholder() {
{!error && !isLoading && didSearch && !hasResults ? (
<div className="mt-4">
<EmptyState
title="No matching foods"
description="Try a broader query like banana, apple, or oats."
title="No complete nutrition records found"
description="Try a broader query like banana, apple, or oats. Records with missing nutrition values are not shown."
/>
</div>
) : null}
Expand Down