Files
backend_fiesta/services/nutritionService_test.go
2026-09-29 22:40:36 +05:30

372 lines
12 KiB
Go

package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"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)
}
}