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

293 lines
11 KiB
Go

package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"nearle/models"
)
/*
The health score on the product screen.
Most of this file is about what the score REFUSES to say. The number itself is
the service's; the judgements around it — which band, whether the match is sure
enough to state plainly, whether the product is even food — are decisions this
code makes on a shopper's behalf, and each one has a way of being wrong that
matters more than being absent.
The fixture is the service's own record for Balaji Wafers Simply Salted, read
from mcp.nearle.ai.in on 29 Sep 2026: a real score of 65.3 on a real product in
a real tenant's catalogue, matched at 0.607 confidence — below the line.
*/
const balajiJSON = `{
"data_status": "verified", "category": "Snacks",
"health_score": 65.3, "nutrition_score": 61.0, "match_confidence": 0.607,
"positive_insights": ["Good source of fibre (9.3 g per 100 g)."],
"nutritional_cautions": ["High in saturated fat (14.4 g per 100 g)."],
"diet_tags": ["Vegetarian"], "allergens": [],
"data_source": "openfoodfacts", "source_url": "https://world.openfoodfacts.org/product/1",
"calories_kcal": 545, "protein_g": 7.5, "total_fat_g": 30.7
}`
func healthOf(t *testing.T, raw string) *models.HealthScore {
t.Helper()
var source NutritionSource
if err := json.Unmarshal([]byte(raw), &source); err != nil {
t.Fatalf("fixture: %v", err)
}
return source.Health()
}
func TestARealScoreReachesTheApp(t *testing.T) {
score := healthOf(t, balajiJSON)
if score == nil {
t.Fatal("no score from a verified, edible, scored product")
}
if score.Score != 65 {
// Rounded: the service sends 65.3 and nobody reads the decimal.
t.Errorf("score = %d, want 65", score.Score)
}
if score.Band != "good" || score.Label != "Healthy" {
t.Errorf("band = %q / %q", score.Band, score.Label)
}
if len(score.Positives) != 1 || len(score.Cautions) != 1 {
t.Errorf("insights lost: %+v / %+v", score.Positives, score.Cautions)
}
if score.Source == nil || score.Source.Label != "openfoodfacts" {
t.Errorf("source lost: %+v", score.Source)
}
}
/* ── What it refuses to say ──────────────────────────────────────────────── */
func TestSomethingThatIsNotFoodGetsNoScore(t *testing.T) {
// The reason this guard exists. Measured 4 Sep 2026, the upstream
// per-product endpoint rated Godrej Hit insecticide 80/100 with
// data_status "verified"; soap and shampoo both scored 37.5 the same way.
// Those records now read "unavailable", and the guard stays: this tenant
// sells soap and toothpaste next to its biscuits.
for _, category := range []string{"Household", "Personal Care", "Insecticide", "Cosmetics"} {
raw := strings.Replace(balajiJSON, `"category": "Snacks"`, `"category": "`+category+`"`, 1)
if score := healthOf(t, raw); score != nil {
t.Errorf("%s was rated %d/100", category, score.Score)
}
}
}
func TestAnUnknownCategoryIsTreatedAsNotFood(t *testing.T) {
// An allowlist, because the two failure modes are not symmetric. Withholding
// a badge from real food costs a shopper something they never had; putting
// one on bleach is a different order of mistake.
for _, category := range []string{`null`, `""`, `"General"`, `"Miscellaneous"`} {
raw := strings.Replace(balajiJSON, `"category": "Snacks"`, `"category": `+category, 1)
if score := healthOf(t, raw); score != nil {
t.Errorf("category %s was scored %d/100", category, score.Score)
}
}
}
func TestAnUnscoredProductGetsNoScore(t *testing.T) {
// The common case: the service knows the product and has not rated it.
raw := strings.Replace(balajiJSON, `"health_score": 65.3`, `"health_score": null`, 1)
if score := healthOf(t, raw); score != nil {
t.Fatalf("invented a score: %+v", score)
}
}
/* ── Saying how sure it is ───────────────────────────────────────────────── */
func TestAWeakMatchIsDeclared(t *testing.T) {
// 0.607 is below the 0.7 line. A nutrition table presented as fact on a 61%
// match is a claim the data does not support.
score := healthOf(t, balajiJSON)
if score.Caveat == "" {
t.Fatal("a 0.607 match was presented as fact")
}
if !strings.Contains(score.Caveat, "61%") {
t.Errorf("the caveat does not say how sure: %q", score.Caveat)
}
}
func TestAStrongMatchNeedsNoApology(t *testing.T) {
raw := strings.Replace(balajiJSON, `"match_confidence": 0.607`, `"match_confidence": 0.94`, 1)
if score := healthOf(t, raw); score.Caveat != "" {
t.Errorf("apologised for a 94%% match: %q", score.Caveat)
}
}
func TestAnEmptyAllergenListOnAWeakMatchIsFlagged(t *testing.T) {
// The one that could actually hurt somebody. The service matches sources
// down to 0.32 confidence, and "verified" speaks to the numbers being real,
// not to the record being THIS product. Silence must not stand in for
// "contains none" — the app has to say "not confirmed" rather than draw
// nothing.
score := healthOf(t, balajiJSON)
if !score.Allergensunconfirmed {
t.Fatal("an empty allergen list on a 61% match was left to speak for itself")
}
}
func TestADeclaredAllergenIsAlwaysShown(t *testing.T) {
// A false positive sends somebody to read the packet. A false negative sends
// them to hospital. So a declared allergen survives a weak match.
raw := strings.Replace(balajiJSON, `"allergens": []`, `"allergens": ["Milk", "Soy"]`, 1)
score := healthOf(t, raw)
if len(score.Allergens) != 2 {
t.Fatalf("a declared allergen was dropped: %+v", score.Allergens)
}
if score.Allergensunconfirmed {
t.Error("a list with entries in it was flagged as unconfirmed")
}
}
func TestAnEmptyListOnAStrongMatchIsNotFlagged(t *testing.T) {
raw := strings.Replace(balajiJSON, `"match_confidence": 0.607`, `"match_confidence": 0.94`, 1)
if healthOf(t, raw).Allergensunconfirmed {
t.Error("flagged an empty list that the match actually supports")
}
}
/* ── Bands ───────────────────────────────────────────────────────────────── */
func TestTheBandThresholdsAreTheServices(t *testing.T) {
// Confirmed by that team in writing. The per-product endpoint sends no
// band, so these are the only path — picking our own would be one API
// disagreeing with itself depending which endpoint a screen called.
for _, tc := range []struct {
score float64
band string
label string
}{
{92, "excellent", "Very healthy"},
{80, "excellent", "Very healthy"},
{79.9, "good", "Healthy"},
{60, "good", "Healthy"},
{59.9, "fair", "Okay"},
{40, "fair", "Okay"},
{39.9, "poor", "Less healthy"},
{0, "poor", "Less healthy"},
} {
band := models.BandFor(tc.score, "")
if band != tc.band || models.BandLabel(band) != tc.label {
t.Errorf("%v → %q/%q, want %q/%q",
tc.score, band, models.BandLabel(band), tc.band, tc.label)
}
}
}
func TestTheServicesOwnBandWinsWhenItSendsOne(t *testing.T) {
// It does not today, from this endpoint. When it starts, its answer is the
// authority and the thresholds above become the fallback they were meant to
// be.
if got := models.BandFor(20, "excellent"); got != "excellent" {
t.Errorf("ignored the service's own band: %q", got)
}
if got := models.BandFor(20, " GOOD "); got != "good" {
t.Errorf("did not normalise the service's band: %q", got)
}
if got := models.BandFor(85, "nonsense"); got != "excellent" {
t.Errorf("trusted a band it does not recognise: %q", got)
}
}
/* ── On the wire ─────────────────────────────────────────────────────────── */
func TestTheScoreAndThePanelAreIndependent(t *testing.T) {
// A product can be scored with no figures published, and carry figures with
// no score. Tying them together would blank one because the other was
// missing.
noFigures := `{"category":"Snacks","health_score":65.3,"match_confidence":0.9}`
var source NutritionSource
if err := json.Unmarshal([]byte(noFigures), &source); err != nil {
t.Fatalf("fixture: %v", err)
}
if source.Panel() != nil {
t.Error("made a panel with no figures")
}
if source.Health() == nil {
t.Error("withheld a score because there were no figures")
}
}
func TestBothHalvesComeFromOneRequest(t *testing.T) {
// Splitting a record the service returns whole would double the traffic to
// a third party on a screen a shopper is waiting on.
var hits int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Balaji"]}`))
return
}
hits++
_, _ = w.Write([]byte(balajiJSON))
}))
defer server.Close()
found := NewNutritionService(server.URL).ForProduct("balaji", "balaji_wafers_135g")
if hits != 1 {
t.Fatalf("made %d requests for one product", hits)
}
if !found.Panel.HasValues() || found.Health == nil {
t.Fatal("one of the two halves was lost")
}
}
func TestTheWireNamesForTheScore(t *testing.T) {
row := models.Products{Productid: 7101}
row.Healthscore = healthOf(t, balajiJSON)
encoded, err := json.Marshal(row)
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)
}
health, ok := out["healthscore"].(map[string]any)
if !ok {
t.Fatalf("no `healthscore` key: %s", encoded)
}
for _, key := range []string{"score", "band", "label", "caveat", "allergensunconfirmed"} {
if _, ok := health[key]; !ok {
t.Errorf("missing %q: %v", key, health)
}
}
}
func TestAProductWithNoScoreHasNoKeyAtAll(t *testing.T) {
// Not `"healthscore": null`. Most products have none.
encoded, _ := json.Marshal(models.Products{Productid: 1})
var out map[string]any
_ = json.Unmarshal(encoded, &out)
if _, present := out["healthscore"]; present {
t.Fatalf("an absent score was sent as a key: %s", encoded)
}
}
func TestSoapOnTheProductScreenCarriesNoScore(t *testing.T) {
// End to end, through the decoration the endpoint actually calls. This is
// the failure a shopper would see: a health rating on a bar of soap.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/brands" {
_, _ = w.Write([]byte(`{"brands":["Godrej"]}`))
return
}
_, _ = w.Write([]byte(strings.Replace(balajiJSON,
`"category": "Snacks"`, `"category": "Personal Care"`, 1)))
}))
defer server.Close()
rows := []models.Products{{Productid: 7121, Productbrand: "godrej", Imageid: "godrej_soap_100g"}}
decorateNutrition(NewNutritionService(server.URL), rows)
if rows[0].Healthscore != nil {
t.Fatalf("a bar of soap was rated %d/100", rows[0].Healthscore.Score)
}
}