health score
This commit is contained in:
371
services/nutritionService_test.go
Normal file
371
services/nutritionService_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user