updates on the backend
This commit is contained in:
99
tests/test_api.py
Normal file
99
tests/test_api.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Smoke tests for the FastAPI layer.
|
||||
|
||||
These deliberately do NOT require a live Postgres/pgvector or Ollama
|
||||
instance - they assert that every endpoint is reachable and degrades
|
||||
gracefully (returns a sensible JSON error/empty-result rather than a
|
||||
500/crash) when those dependencies are unavailable, which is the
|
||||
situation on a fresh checkout before `.env` + the database are set up.
|
||||
|
||||
For tests that exercise real retrieval/generation against a live
|
||||
database and Ollama, see `tests/test_rag_integration.py` (skipped
|
||||
automatically unless `RUN_INTEGRATION_TESTS=1` is set - see that file).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
# Make sure required-but-not-secret-critical settings have *something* set
|
||||
# before app.main is imported, so settings.py's _require() checks don't
|
||||
# blow up the test run when no real .env is present.
|
||||
os.environ.setdefault("USE_PGVECTOR", "true")
|
||||
os.environ.setdefault("DB_PASSWORD", "test-password-not-real")
|
||||
os.environ.setdefault("USE_S3", "false")
|
||||
os.environ.setdefault("USE_GOOGLE_CSE", "false")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_root() -> None:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
if "text/html" in resp.headers.get("content-type", ""):
|
||||
assert "<html" in resp.text.lower()
|
||||
else:
|
||||
assert "service" in resp.json()
|
||||
|
||||
|
||||
def test_health_degrades_gracefully_without_dependencies() -> None:
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] in {"ok", "degraded"}
|
||||
assert isinstance(body["database"], bool)
|
||||
assert isinstance(body["ollama"], bool)
|
||||
|
||||
|
||||
def test_brands_returns_empty_list_without_database() -> None:
|
||||
resp = client.get("/api/brands")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"brands": []}
|
||||
|
||||
|
||||
def test_brand_products_returns_empty_without_database() -> None:
|
||||
resp = client.get("/api/brands/Parle/products")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["brand"] == "Parle"
|
||||
assert body["products"] == []
|
||||
assert body["total"] == 0
|
||||
|
||||
|
||||
def test_product_detail_404_when_missing() -> None:
|
||||
resp = client.get("/api/brands/Parle/products/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_chat_validates_empty_query() -> None:
|
||||
resp = client.post("/api/chat", json={"query": ""})
|
||||
assert resp.status_code == 422 # min_length=1 violated
|
||||
|
||||
|
||||
def test_chat_rejects_too_many_top_k() -> None:
|
||||
resp = client.post("/api/chat", json={"query": "snacks", "top_k": 999})
|
||||
assert resp.status_code == 422 # le=15 violated
|
||||
|
||||
|
||||
def test_catalog_generate_returns_job_id() -> None:
|
||||
resp = client.post("/api/catalog/generate", json={"brand": "TestBrand", "max_products": 1})
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["brand"] == "TestBrand"
|
||||
assert body["status"] in {"pending", "running", "done", "failed"}
|
||||
|
||||
|
||||
def test_openapi_schema_lists_all_routers() -> None:
|
||||
resp = client.get("/openapi.json")
|
||||
assert resp.status_code == 200
|
||||
paths = resp.json()["paths"]
|
||||
for expected in ("/api/health", "/api/brands", "/api/search", "/api/chat", "/api/catalog/generate"):
|
||||
assert expected in paths, f"missing route: {expected}"
|
||||
104
tests/test_nutrition_scoring.py
Normal file
104
tests/test_nutrition_scoring.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Pure unit tests for `nutrition_scoring.py` - no DB, no network, no LLM.
|
||||
Run with: pytest tests/test_nutrition_scoring.py -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.services import nutrition_scoring # noqa: E402
|
||||
|
||||
|
||||
def _verified(**overrides):
|
||||
base = {
|
||||
"data_status": "verified",
|
||||
"calories_kcal": 250, "protein_g": 8, "carbohydrates_g": 40,
|
||||
"total_sugar_g": 10, "added_sugar_g": None, "dietary_fiber_g": 4,
|
||||
"total_fat_g": 8, "saturated_fat_g": 2, "trans_fat_g": 0,
|
||||
"sodium_mg": 300, "calcium_mg": None, "iron_mg": None,
|
||||
"vitamin_c_mg": None, "vitamin_d_mcg": None, "vitamin_a_mcg": None,
|
||||
"potassium_mg": None, "zinc_mg": None, "magnesium_mg": None,
|
||||
"ingredients_text": None,
|
||||
"off_labels_tags": [], "off_ingredients_analysis_tags": [], "allergens": [],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_unavailable_facts_produce_no_score():
|
||||
assert nutrition_scoring.compute_scores({"data_status": "unavailable"}) is None
|
||||
|
||||
|
||||
def test_unavailable_facts_produce_no_insights_or_cautions():
|
||||
facts = {"data_status": "unavailable"}
|
||||
assert nutrition_scoring.generate_positive_insights(facts) == []
|
||||
assert nutrition_scoring.generate_cautions(facts) == []
|
||||
assert nutrition_scoring.classify_diet_tags(facts) == []
|
||||
|
||||
|
||||
def test_score_never_fabricates_missing_micronutrients():
|
||||
"""A product with zero verified micronutrient fields should still
|
||||
score (on the fields that ARE verified) rather than crash or treat
|
||||
missing micronutrients as zero."""
|
||||
facts = _verified()
|
||||
result = nutrition_scoring.compute_scores(facts)
|
||||
assert result is not None
|
||||
assert "micronutrient" not in result["score_breakdown"]["fields_used"]
|
||||
assert "micronutrient" in result["score_breakdown"]["fields_missing"]
|
||||
|
||||
|
||||
def test_high_sugar_triggers_caution_not_low_score_silently():
|
||||
facts = _verified(total_sugar_g=30.0)
|
||||
cautions = nutrition_scoring.generate_cautions(facts)
|
||||
assert any("sugar" in c.lower() for c in cautions)
|
||||
result = nutrition_scoring.compute_scores(facts)
|
||||
low_sugar_facts = _verified(total_sugar_g=2.0)
|
||||
result_low_sugar = nutrition_scoring.compute_scores(low_sugar_facts)
|
||||
assert result["score_breakdown"]["sugar_score"] < result_low_sugar["score_breakdown"]["sugar_score"]
|
||||
|
||||
|
||||
def test_low_sugar_and_low_sodium_get_positive_insight():
|
||||
facts = _verified(total_sugar_g=3.0, sodium_mg=80.0)
|
||||
positives = nutrition_scoring.generate_positive_insights(facts)
|
||||
assert any("sugar" in p.lower() for p in positives)
|
||||
assert any("sodium" in p.lower() for p in positives)
|
||||
|
||||
|
||||
def test_high_protein_diet_tag_from_energy_ratio():
|
||||
# protein 25g * 4 kcal/g = 100 kcal of 300 kcal total = 33% >= 20% threshold
|
||||
facts = _verified(protein_g=25.0, calories_kcal=300.0)
|
||||
tags = nutrition_scoring.classify_diet_tags(facts)
|
||||
assert "High Protein" in tags
|
||||
|
||||
|
||||
def test_vegan_implies_vegetarian_and_dairy_free():
|
||||
facts = _verified(off_ingredients_analysis_tags=["en:vegan-status-vegan"])
|
||||
tags = nutrition_scoring.classify_diet_tags(facts)
|
||||
assert "Vegan" in tags
|
||||
assert "Vegetarian" in tags
|
||||
assert "Dairy Free" in tags
|
||||
|
||||
|
||||
def test_health_score_penalizes_calorie_density():
|
||||
light = _verified(calories_kcal=100.0)
|
||||
dense = _verified(calories_kcal=550.0)
|
||||
light_result = nutrition_scoring.compute_scores(light)
|
||||
dense_result = nutrition_scoring.compute_scores(dense)
|
||||
assert dense_result["health_score"] < light_result["health_score"]
|
||||
# nutrition_score (pre-calorie-penalty) should be identical since
|
||||
# calories alone isn't a scored component other than via the
|
||||
# protein-energy-ratio path, which both share here.
|
||||
|
||||
|
||||
def test_trans_fat_always_flagged():
|
||||
facts = _verified(trans_fat_g=0.6)
|
||||
cautions = nutrition_scoring.generate_cautions(facts)
|
||||
assert any("trans fat" in c.lower() for c in cautions)
|
||||
|
||||
|
||||
def test_allergens_normalized_and_deduplicated():
|
||||
facts = _verified(allergens=["Milk", "milk", "Peanut"])
|
||||
assert nutrition_scoring.normalize_allergens(facts) == ["Milk", "Peanut"]
|
||||
178
tests/test_query_intent.py
Normal file
178
tests/test_query_intent.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Unit tests for the category-aware RAG retrieval fix.
|
||||
|
||||
These are pure-Python tests (no Postgres, no Ollama, no embeddings model)
|
||||
covering the root cause of the "biscuit query returns snacks" bug and its
|
||||
fix: `app/services/category_registry.py` and `app/services/query_intent.py`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.services.category_registry import ( # noqa: E402
|
||||
category_matches,
|
||||
detect_category_from_text,
|
||||
sanitize_category_language,
|
||||
)
|
||||
from app.services.query_intent import extract_attributes # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_category_from_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detects_biscuits_from_the_reported_bug_query() -> None:
|
||||
assert detect_category_from_text("Recommend biscuits with low sugar") == "Biscuits & Cookies"
|
||||
|
||||
|
||||
def test_detects_snacks() -> None:
|
||||
assert detect_category_from_text("what chips or namkeen do you have") == "Snacks"
|
||||
|
||||
|
||||
def test_detects_cooking_oil() -> None:
|
||||
assert detect_category_from_text("best cooking oil for deep frying") == "Cooking Oils"
|
||||
|
||||
|
||||
def test_detects_oral_care() -> None:
|
||||
assert detect_category_from_text("recommend a toothpaste for sensitive teeth") == "Oral Care"
|
||||
|
||||
|
||||
def test_prioritizes_biscuits_over_chocolate_modifier() -> None:
|
||||
# "biscuit" is the head noun (the product type); "chocolate" is a
|
||||
# flavor modifier. Biscuits & Cookies is listed earlier in the
|
||||
# registry specifically so this resolves correctly.
|
||||
assert detect_category_from_text("chocolate biscuits under 30 rupees") == "Biscuits & Cookies"
|
||||
|
||||
|
||||
def test_no_category_keyword_returns_none() -> None:
|
||||
assert detect_category_from_text("hello, how are you today?") is None
|
||||
assert detect_category_from_text("what's the price of ITC Bingo?") is None
|
||||
|
||||
|
||||
def test_case_insensitive_and_plural() -> None:
|
||||
assert detect_category_from_text("SNACKS please") == "Snacks"
|
||||
assert detect_category_from_text("a cookie") == "Biscuits & Cookies"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# category_matches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_category_matches_exact() -> None:
|
||||
assert category_matches("Biscuits & Cookies", "Biscuits & Cookies") is True
|
||||
|
||||
|
||||
def test_category_matches_normalizes_ampersand_and_case() -> None:
|
||||
assert category_matches("biscuits and cookies", "Biscuits & Cookies") is True
|
||||
assert category_matches("BISCUITS & COOKIES", "biscuits & cookies") is True
|
||||
|
||||
|
||||
def test_category_matches_rejects_the_reported_bug_case() -> None:
|
||||
# This is the exact defect: a Snacks product must NOT match a
|
||||
# Biscuits & Cookies target category.
|
||||
assert category_matches("Snacks", "Biscuits & Cookies") is False
|
||||
|
||||
|
||||
def test_category_matches_true_when_no_target() -> None:
|
||||
assert category_matches("Snacks", None) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sanitize_category_language
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sanitizes_the_actual_bingo_korean_style_description() -> None:
|
||||
original = (
|
||||
"A classic snack with a unique twist - ITC Bingo Korean Style is a "
|
||||
"crispy, savory biscuit that adds a touch of spice and Korean flavors."
|
||||
)
|
||||
cleaned = sanitize_category_language(original, "Snacks")
|
||||
assert "biscuit" not in cleaned.lower()
|
||||
assert "crispy, savory snack" in cleaned
|
||||
|
||||
|
||||
def test_sanitizes_the_actual_parle_monaco_description() -> None:
|
||||
original = (
|
||||
"A crunchy, salty delight with a creamy twist, Parle Monaco is a "
|
||||
"popular snack that combines the taste of cheese and crackers."
|
||||
)
|
||||
cleaned = sanitize_category_language(original, "Crackers")
|
||||
assert "is a popular cracker" in cleaned
|
||||
# Flavor mentions are not identity claims and must be left alone.
|
||||
assert "taste of cheese" in cleaned
|
||||
|
||||
|
||||
def test_does_not_touch_flavor_mentions_without_identity_claim() -> None:
|
||||
original = "Tender and crispy Parle Hide & Seek biscuits, perfect for a quick afternoon snack!"
|
||||
cleaned = sanitize_category_language(original, "Biscuits & Cookies")
|
||||
# "snack" here isn't an identity claim ("is a snack"), just an idiom
|
||||
# ("afternoon snack") - must be left untouched.
|
||||
assert cleaned == original
|
||||
|
||||
|
||||
def test_does_not_touch_own_category_words() -> None:
|
||||
original = "Parle Kismi is a delicious and healthy candy, perfect for a sweet treat."
|
||||
cleaned = sanitize_category_language(original, "Candy & Confectionery")
|
||||
assert cleaned == original
|
||||
|
||||
|
||||
def test_empty_description_is_noop() -> None:
|
||||
assert sanitize_category_language("", "Snacks") == ""
|
||||
assert sanitize_category_language(None, "Snacks") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_attributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_extracts_low_sugar_from_the_reported_bug_query() -> None:
|
||||
assert extract_attributes("Recommend biscuits with low sugar") == {"sugar": "low"}
|
||||
|
||||
|
||||
def test_extracts_sugar_free() -> None:
|
||||
assert extract_attributes("any sugar-free candy?")["sugar"] == "none"
|
||||
|
||||
|
||||
def test_extracts_spicy() -> None:
|
||||
assert extract_attributes("something spicy and crunchy")["spice"] == "high"
|
||||
|
||||
|
||||
def test_no_attributes_returns_empty_dict() -> None:
|
||||
assert extract_attributes("show me Parle biscuits") == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New Intent Extractor & Typo Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.services.query_intent import is_count_query, extract_max_price, extract_brand_mention
|
||||
|
||||
|
||||
def test_detects_typos_in_category() -> None:
|
||||
assert detect_category_from_text("Suggest panner less than ₹100") == "Dairy"
|
||||
assert detect_category_from_text("Show me biscit options") == "Biscuits & Cookies"
|
||||
assert detect_category_from_text("chocate dark") == "Chocolates"
|
||||
|
||||
|
||||
def test_is_count_query() -> None:
|
||||
assert is_count_query("How many products are there in Cadbury?") is True
|
||||
assert is_count_query("What is the total number of items in Amul?") is True
|
||||
assert is_count_query("how many items under snacks?") is True
|
||||
assert is_count_query("Recommend Paneer under ₹150") is False
|
||||
|
||||
|
||||
def test_extract_max_price() -> None:
|
||||
assert extract_max_price("Recommend Paneer under ₹150") == 150.0
|
||||
assert extract_max_price("Suggest panner less than ₹100") == 100.0
|
||||
assert extract_max_price("biscuits below 50 rupees") == 50.0
|
||||
assert extract_max_price("what paneer do you have?") is None
|
||||
|
||||
|
||||
def test_extract_brand_mention() -> None:
|
||||
assert extract_brand_mention("How many products are there in Cadbury?") == "Cadbury"
|
||||
assert extract_brand_mention("show me Amul butter") == "Amul"
|
||||
assert extract_brand_mention("any Nestle chocolates?") == "Nestle"
|
||||
assert extract_brand_mention("suggest low sugar biscuits") is None
|
||||
Reference in New Issue
Block a user