Files
backend_fiesta/services/nutritionService_test.go
abhishek c49f5372a5 nutrition: do not cache a lookup made under an unresolved brand
Brand case decides whether the catalogue-intelligence service answers at all.
Measured 30 Sep 2026:

    /nutrition/Balaji/balaji_..._135g   -> health_score 65.3, 545 kcal
    /nutrition/balaji/...  (our spelling) -> every field null

The brand list resolves ours to theirs, and /brands has slowed to 0.2-2.3s,
which exceeded the 3s client timeout on a cold start. The fallback then asked
under our own spelling, received a well-formed empty record, and cached it as
"no nutrition" for six hours -- so one slow moment silently removed nutrition
and health scores from every product of every brand, looking exactly like data
the agent team had not supplied.

Two changes:

  - a result reached without a resolved brand is no longer cached, so the next
    request retries rather than inheriting a wrong answer for six hours. A
    genuine miss on a resolved brand is still cached, which is the case that
    matters for traffic.
  - the brand list is warmed in the background at startup, so no shopper is
    ever in the path of that call.

Also logs which state the feature is in at boot, the way mail does. With
NUTRITION_BASE unset the endpoint simply omits `nutrition` and `healthscore`,
which is indistinguishable from an unscored product -- this deploy went out
without the variable set and had to be diagnosed by probing the API from
outside.

scratch/nutritionlive prints the exact response for any product by running this
code against the live product row and the live service.

NUTRITION_BASE=https://mcp.nearle.ai.in/api must be set in the deployment
environment. Unset, nothing changes and no product carries either key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 11:48:27 +05:30

450 lines
15 KiB
Go

package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"nearle/models"
)
/*
The nutrition panel on the product screen.
The fixture is the catalogue-intelligence service's own record for Cadbury
5 Star, read from mcp.nearle.ai.in on 29 Sep 2026 — including the awkward parts:
a null `added_sugar_g`, a `dietary_fiber_g` of exactly 0, and an
`extended_nutrients` map carrying its own unit.
*/
const fiveStarJSON = `{
"data_status": "verified",
"serving_size_label": "1 mini (11 g)",
"serving_size_g": 11,
"calories_kcal": 525.0, "protein_g": 6.8, "carbohydrates_g": 62.6,
"total_sugar_g": 58.8, "added_sugar_g": null, "dietary_fiber_g": 0.0,
"total_fat_g": 27.9, "saturated_fat_g": 18.7, "trans_fat_g": null,
"cholesterol_mg": null, "sodium_mg": 107,
"extended_nutrients": {"Salt": {"unit": "g", "value": 0.268}}
}`
// The answer for a product the service knows nothing about — which is most of
// them, including the Patanjali ghee this work started from.
const unavailableJSON = `{
"data_status": "unavailable", "serving_size_label": null,
"calories_kcal": null, "protein_g": null, "sodium_mg": null,
"extended_nutrients": null
}`
func panelOf(t *testing.T, raw string) *models.NutritionPanel {
t.Helper()
var source NutritionSource
if err := json.Unmarshal([]byte(raw), &source); err != nil {
t.Fatalf("fixture: %v", err)
}
return source.Panel()
}
func TestARealRecordBecomesTheAppsPanel(t *testing.T) {
panel := panelOf(t, fiveStarJSON)
if !panel.HasValues() {
t.Fatal("no panel from a verified record")
}
if panel.Per != "100g" {
// The service's top-level figures are per 100g — its own insights say
// "6.8 g per 100 g" and the console prints the same basis.
t.Errorf("per = %q, want 100g", panel.Per)
}
if panel.Servingsize != "1 mini (11 g)" {
t.Errorf("servingsize = %q", panel.Servingsize)
}
want := map[string][2]any{
"Energy": {525.0, "kcal"},
"Protein": {6.8, "g"},
"Carbohydrate": {62.6, "g"},
"Total Sugars": {58.8, "g"},
"Dietary Fibre": {0.0, "g"},
"Total Fat": {27.9, "g"},
"Saturated Fat": {18.7, "g"},
"Sodium": {107.0, "mg"},
"Salt": {0.268, "g"},
}
got := map[string][2]any{}
for _, item := range panel.Items {
got[item.Name] = [2]any{item.Value, item.Unit}
}
for name, expected := range want {
if got[name] != expected {
t.Errorf("%s = %v, want %v", name, got[name], expected)
}
}
if len(panel.Items) != len(want) {
t.Errorf("got %d rows, want %d: %+v", len(panel.Items), len(want), panel.Items)
}
}
func TestAFieldTheServiceDidNotStateIsNotARow(t *testing.T) {
// `added_sugar_g` is null on this record. A table of dashes is worse than a
// shorter table, and a 0 would claim the product has no added sugar.
for _, item := range panelOf(t, fiveStarJSON).Items {
if item.Name == "Added Sugars" || item.Name == "Trans Fat" || item.Name == "Cholesterol" {
t.Errorf("invented a row the service left null: %+v", item)
}
}
}
func TestAGenuineZeroIsKept(t *testing.T) {
// `dietary_fiber_g` is 0.0, not null. "No fibre" is a fact the label states
// and dropping it would lose it — the opposite of the null case above.
var found bool
for _, item := range panelOf(t, fiveStarJSON).Items {
if item.Name == "Dietary Fibre" {
found = true
if item.Value != 0 {
t.Errorf("got %v, want 0", item.Value)
}
}
}
if !found {
t.Error("a stated zero was dropped")
}
}
func TestAnUnavailableProductGetsNoPanel(t *testing.T) {
// The common case today: the service knows the product and has nothing. An
// empty panel on the screen reads as "this food has no nutrition".
if panel := panelOf(t, unavailableJSON); panel != nil {
t.Fatalf("made a panel out of nulls: %+v", panel)
}
}
func TestTheRowOrderIsStable(t *testing.T) {
// `extended_nutrients` is a map, and Go map order is randomised. A panel
// that reshuffles between two requests for the same product looks broken.
first := panelOf(t, fiveStarJSON)
for i := 0; i < 20; i++ {
next := panelOf(t, fiveStarJSON)
for j := range first.Items {
if next.Items[j].Name != first.Items[j].Name {
t.Fatalf("row %d moved: %s then %s", j, first.Items[j].Name, next.Items[j].Name)
}
}
}
}
/* ── The client ──────────────────────────────────────────────────────────── */
func TestTheBrandSpellingIsResolvedBeforeTheLookup(t *testing.T) {
// Ours is `patanjali`, theirs is `Patanjali`. A wrong spelling returns a
// well-formed record with every figure null — indistinguishable from an
// unscored product — so this failing is silent and total.
var asked string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Patanjali","Coca-Cola","Brooke Bond"]}`))
return
}
asked = r.URL.Path
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
if found := service.ForProduct("patanjali", "patanjali_cow_ghee_500ml"); !found.Panel.HasValues() {
t.Fatal("no panel")
}
if asked != "/nutrition/Patanjali/patanjali_cow_ghee_500ml" {
t.Fatalf("asked for %q", asked)
}
}
func TestAnUnderscoreIsNotGuessedAt(t *testing.T) {
// It becomes a hyphen for Coca-Cola and a space for Brooke Bond. Which one
// cannot be derived, so the list is matched rather than transformed.
var asked string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Coca-Cola","Brooke Bond"]}`))
return
}
asked = r.URL.Path
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
NewNutritionService(server.URL).ForProduct("brooke_bond", "bb_tea_250g")
if asked != "/nutrition/Brooke Bond/bb_tea_250g" {
t.Fatalf("asked for %q", asked)
}
}
func TestAnUnknownBrandStillGetsATry(t *testing.T) {
// A brand they have not listed yet is attempted with our spelling rather
// than dropped — the list is their vocabulary, not a gate.
var asked string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Amul"]}`))
return
}
asked = r.URL.Path
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
NewNutritionService(server.URL).ForProduct("newbrand", "nb_thing_1kg")
if asked != "/nutrition/newbrand/nb_thing_1kg" {
t.Fatalf("asked for %q", asked)
}
}
func TestAProductScreenSurvivesTheServiceBeingDown(t *testing.T) {
// The whole reason this returns a panel and never an error. A shopper
// tapped a product; they are owed the screen with or without nutrition.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
server.Close() // refused connections, not merely 500s
if found := NewNutritionService(server.URL).ForProduct("cadbury", "x"); found.Panel != nil {
t.Fatalf("got a panel from a dead service: %+v", found.Panel)
}
}
func TestAMalformedResponseIsNotAPanel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":[]}`))
return
}
_, _ = w.Write([]byte(`{"calories_kcal":`))
}))
defer server.Close()
if found := NewNutritionService(server.URL).ForProduct("cadbury", "x"); found.Panel != nil {
t.Fatalf("read a panel out of broken JSON: %+v", found.Panel)
}
}
func TestTheSameProductIsNotFetchedTwice(t *testing.T) {
// This runs on a screen a shopper is waiting on, once per member of a
// variant group. Nutrition for a packaged product does not change during a
// trading day.
var hits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Cadbury"]}`))
return
}
hits++
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
for i := 0; i < 5; i++ {
service.ForProduct("cadbury", "cadbury_5_star_200g")
}
if hits != 1 {
t.Fatalf("fetched %d times, want 1", hits)
}
}
func TestAMissIsCachedToo(t *testing.T) {
// Most products are unscored. Re-asking on every tap would mean the least
// useful answer costing the most requests.
var hits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Patanjali"]}`))
return
}
hits++
_, _ = w.Write([]byte(unavailableJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
for i := 0; i < 4; i++ {
service.ForProduct("patanjali", "patanjali_cow_ghee_500ml")
}
if hits != 1 {
t.Fatalf("fetched %d times, want 1", hits)
}
}
func TestAProductWithNoJoinKeyIsNotLookedUp(t *testing.T) {
// Sheet-imported products have no image_id. There is nothing to look up,
// which is different from something that failed to be found.
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Error("called the service for a product with no image_id")
}))
defer server.Close()
service := NewNutritionService(server.URL)
service.ForProduct("cadbury", "")
service.ForProduct("", "some_image_id")
}
func TestNoServiceConfiguredIsNotACrash(t *testing.T) {
// Nil is a working configuration: a deployment without the service serves
// every product screen, without a panel.
if NewNutritionService("") != nil {
t.Fatal("built a client with no base URL")
}
rows := []models.Products{{Productid: 1, Productbrand: "cadbury", Imageid: "x"}}
decorateNutrition(nil, rows) // must not panic
if rows[0].Nutrition != nil {
t.Fatal("invented a panel with no service")
}
}
func TestEverySiblingInAVariantGroupIsDecorated(t *testing.T) {
// A shopper switching from 500ml to 1L must not watch the panel vanish.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Cadbury"]}`))
return
}
_, _ = w.Write([]byte(fiveStarJSON))
}))
defer server.Close()
rows := []models.Products{
{Productid: 1, Productbrand: "cadbury", Imageid: "a"},
{Productid: 2, Productbrand: "cadbury", Imageid: "b"},
{Productid: 3, Productbrand: "cadbury", Imageid: ""},
}
decorateNutrition(NewNutritionService(server.URL), rows)
if !rows[0].Nutrition.HasValues() || !rows[1].Nutrition.HasValues() {
t.Fatal("a sibling lost its panel")
}
if rows[2].Nutrition != nil {
t.Fatal("invented a panel for a product with no join key")
}
}
func TestTheWireNamesAreTheOnesTheAppAskedFor(t *testing.T) {
// An API is a promise to a client already written against it. `servingsize`
// is not how the rest of this codebase would spell it, and it is what was
// asked for, which outranks house style.
rows := []models.Products{{Productid: 7093}}
rows[0].Nutrition = panelOf(t, fiveStarJSON)
encoded, err := json.Marshal(rows[0])
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(encoded, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
nutrition, ok := out["nutrition"].(map[string]any)
if !ok {
t.Fatalf("no `nutrition` key: %s", encoded)
}
for _, key := range []string{"per", "servingsize", "items"} {
if _, ok := nutrition[key]; !ok {
t.Errorf("missing %q", key)
}
}
item := nutrition["items"].([]any)[0].(map[string]any)
for _, key := range []string{"name", "value", "unit"} {
if _, ok := item[key]; !ok {
t.Errorf("item missing %q", key)
}
}
}
func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
// Not `"nutrition": null`. Most products have none, and a null on every row
// is payload spent saying nothing.
encoded, _ := json.Marshal(models.Products{Productid: 1})
var out map[string]any
_ = json.Unmarshal(encoded, &out)
if _, present := out["nutrition"]; present {
t.Fatalf("an absent panel was sent as a key: %s", encoded)
}
}
/* ── Not caching an answer we do not trust ───────────────────────────────── */
func TestAnUnresolvedBrandIsNotCachedAsAMiss(t *testing.T) {
// The failure this prevents, measured 30 Sep 2026:
// `/nutrition/Balaji/balaji_..._135g` returns a 65.3 score and 545 kcal;
// `/nutrition/balaji/...` — our own spelling — returns a well-formed record
// with every field null. So a lookup made before the brand list arrived
// does not mean "no nutrition", it means we asked the wrong question.
//
// Cached, one slow moment on /brands would silently remove nutrition from
// every product of every brand for six hours.
var brandsUp bool
var productHits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
if !brandsUp {
w.WriteHeader(http.StatusGatewayTimeout)
return
}
_, _ = w.Write([]byte(`{"brands":["Balaji"]}`))
return
}
productHits++
if strings.Contains(r.URL.Path, "/Balaji/") {
_, _ = w.Write([]byte(balajiJSON))
return
}
// What the service actually answers for the wrong spelling.
_, _ = w.Write([]byte(unavailableJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
// Brand list down: the answer is a miss, and must not stick.
if found := service.ForProduct("balaji", "balaji_wafers_135g"); found.Panel != nil {
t.Fatal("got a panel from the wrong spelling")
}
// Brand list back: the same product must be asked again, correctly.
brandsUp = true
found := service.ForProduct("balaji", "balaji_wafers_135g")
if !found.Panel.HasValues() {
t.Fatal("the miss was cached — a slow /brands has cost six hours of nutrition")
}
if found.Health == nil || found.Health.Score != 65 {
t.Fatalf("score lost: %+v", found.Health)
}
if productHits < 2 {
t.Fatalf("the product was only asked for %d time(s)", productHits)
}
}
func TestAResolvedMissIsStillCached(t *testing.T) {
// The other half: when the brand IS theirs and they simply have nothing,
// that is a real answer and re-asking on every tap wastes the most
// requests on the least useful result.
var productHits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Patanjali"]}`))
return
}
productHits++
_, _ = w.Write([]byte(unavailableJSON))
}))
defer server.Close()
service := NewNutritionService(server.URL)
for i := 0; i < 4; i++ {
service.ForProduct("patanjali", "patanjali_cow_ghee_500ml")
}
if productHits != 1 {
t.Fatalf("asked %d times for a product the service has answered about", productHits)
}
}