293 lines
11 KiB
Go
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)
|
|
}
|
|
}
|