372 lines
12 KiB
Go
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)
|
|
}
|
|
}
|