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) } }