health score

This commit is contained in:
2026-09-29 22:40:36 +05:30
parent f9fb405974
commit fb859ecda1
21 changed files with 1590 additions and 703 deletions

View File

@@ -0,0 +1,292 @@
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)
}
}

View File

@@ -1,148 +0,0 @@
package services
import (
"encoding/json"
"testing"
"nearle/models"
)
/*
What the product screen receives.
The app renders a nutrition panel off `getproductbyvariant`. These are about the
contract that screen is built on — the exact field names, and what happens for
the overwhelming majority of products, which have no structured nutrition at all
and are not going to for a while.
*/
func TestTheStructuredPanelIsUsedWhereItExists(t *testing.T) {
facts := `{"nutrition":{"per":"100g","servingsize":"30g","items":[
{"name":"Energy","value":520,"unit":"kcal"},
{"name":"Protein","value":6.5,"unit":"g"}]}}`
panel := nutritionFromFacts(facts)
if !panel.HasValues() {
t.Fatal("no panel")
}
if panel.Per != "100g" || panel.Servingsize != "30g" {
t.Fatalf("basis lost: per=%q servingsize=%q", panel.Per, panel.Servingsize)
}
if len(panel.Items) != 2 || panel.Items[1].Value != 6.5 {
t.Fatalf("items wrong: %+v", panel.Items)
}
}
func TestTheOldNutrientLinesStillProduceAPanel(t *testing.T) {
// This is the case that actually matters on day one: every product on the
// platform carries display lines and nothing else. Without this the app
// ships a nutrition screen that is empty for every product in every shop.
panel := nutritionFromFacts(`{"nutrients":["Energy 350kcal","Protein 7g"]}`)
if !panel.HasValues() {
t.Fatal("the lines were not used")
}
if panel.Items[0].Name != "Energy" || panel.Items[0].Value != 350 {
t.Fatalf("got %+v", panel.Items[0])
}
}
func TestStructuredBeatsLinesWhenBothArePresent(t *testing.T) {
// The import keeps both — the console shows the lines, the app shows the
// panel. The structured one is the agent team's work and carries a basis,
// so it wins.
facts := `{"nutrients":["Energy 350kcal"],
"nutrition":{"per":"100g","items":[{"name":"Energy","value":520,"unit":"kcal"}]}}`
panel := nutritionFromFacts(facts)
if panel.Per != "100g" || panel.Items[0].Value != 520 {
t.Fatalf("the derived panel won: %+v", panel)
}
}
func TestNothingKnownMeansNoPanel(t *testing.T) {
for _, facts := range []string{"", " ", "{}", `{"highlights":["Crunchy"]}`} {
if p := nutritionFromFacts(facts); p != nil {
t.Errorf("%q produced a panel: %+v", facts, p)
}
}
}
func TestAMalformedSnapshotCostsThePanelAndNothingElse(t *testing.T) {
// One bad blob must not fail the response for the variant group it is in —
// the shopper tapped a product and is owed the screen.
if p := nutritionFromFacts(`{"nutrition":{"items":`); p != nil {
t.Fatalf("read a panel out of broken JSON: %+v", p)
}
}
func TestAPanelWithNoRowsIsNotSent(t *testing.T) {
// `items: []` renders as an empty box, which a shopper reads as "this food
// has no nutrition" rather than "we do not know".
if p := nutritionFromFacts(`{"nutrition":{"per":"100g","items":[]}}`); p != nil {
t.Fatalf("sent an empty panel: %+v", p)
}
}
func TestEveryRowInAVariantGroupIsDecorated(t *testing.T) {
// A shopper switching from 500ml to 1L must not watch the nutrition vanish.
rows := []models.Products{
{Productid: 1, Cataloguefacts: `{"nutrients":["Energy 350kcal"]}`},
{Productid: 2, Cataloguefacts: `{"nutrition":{"per":"100g","items":[{"name":"Energy","value":700,"unit":"kcal"}]}}`},
{Productid: 3},
}
decorateNutrition(rows)
if !rows[0].Nutrition.HasValues() || !rows[1].Nutrition.HasValues() {
t.Fatal("a sibling was left without its panel")
}
if rows[2].Nutrition != nil {
t.Fatal("invented a panel for a product with no facts")
}
}
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 file would spell it, and it is what was asked
// for, which outranks house style.
rows := []models.Products{{
Productid: 7093,
Cataloguefacts: `{"nutrition":{"per":"100g","servingsize":"30g","items":[{"name":"Energy","value":520,"unit":"kcal"}]}}`,
}}
decorateNutrition(rows)
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 on the product: %s", encoded)
}
for _, key := range []string{"per", "servingsize", "items"} {
if _, ok := nutrition[key]; !ok {
t.Errorf("missing %q: %v", key, nutrition)
}
}
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: %v", key, item)
}
}
}
func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
// Not `"nutrition": null`. Most products have none, and a null on every row
// of a mobile response 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)
}
}

View File

@@ -0,0 +1,468 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"nearle/models"
"regexp"
"sort"
"strings"
"sync"
"time"
)
/*
Nutrition for the customer app's product screen.
── Where the figures come from ─────────────────────────────────────────────
`mcp.nearle.ai.in` — the catalogue-intelligence service, the same one that
scrapes the global catalogue and backs the health score card in the console.
Not Fiesta's own database: nothing on `products` has ever held nutrition, and
the service already has it keyed by brand and image_id, which is exactly what a
tenant's product carries from import.
So this reads it rather than copying it. A second store of the same figures is a
second thing to keep in step, and the one that drifts is the one on a food label.
── Why the console does this in the browser and the app cannot ─────────────
The console calls the service directly from `api/nutrition.ts`. The customer app
could too, in principle, and should not have to: it would mean a second base
URL, a second failure mode and the brand-spelling problem below reimplemented in
whatever the app is written in. The product screen already calls Fiesta, so
Fiesta answers the whole question.
*/
// NutritionSource is the shape the catalogue-intelligence service answers with.
//
// Only the fields the panel needs. The service returns roughly forty, including
// scores, insights, allergens and diet tags — all of which belong to the health
// score card in the console and none of which the app asked for.
type NutritionSource struct {
// "verified" when the source record was confirmed, "unavailable" when the
// service knows the product and has nothing for it.
DataStatus string `json:"data_status"`
// The health score half of the same record.
HealthScore *float64 `json:"health_score"`
// Absent from this endpoint — only the list sends it — so the band is
// derived. See models.BandFor.
HealthBand string `json:"health_band"`
// What the service thinks the product IS. The edibility guard reads this,
// and it is routinely null, which the guard treats as "not food".
Category string `json:"category"`
// 0–1. How sure the service is it matched the right source record. Below
// models.LowConfidence the figures are a guide, not a fact.
MatchConfidence *float64 `json:"match_confidence"`
PositiveInsights []string `json:"positive_insights"`
NutritionalCautions []string `json:"nutritional_cautions"`
DietTags []string `json:"diet_tags"`
Allergens []string `json:"allergens"`
DataSource string `json:"data_source"`
SourceURL string `json:"source_url"`
ServingSizeLabel string `json:"serving_size_label"`
// Per 100g, every one of them. The service's own insights say so — "6.8 g
// per 100 g" — and the console prints "per 100 g" beneath the same numbers.
Calories *float64 `json:"calories_kcal"`
Protein *float64 `json:"protein_g"`
Carbohydrates *float64 `json:"carbohydrates_g"`
TotalSugar *float64 `json:"total_sugar_g"`
AddedSugar *float64 `json:"added_sugar_g"`
DietaryFiber *float64 `json:"dietary_fiber_g"`
TotalFat *float64 `json:"total_fat_g"`
SaturatedFat *float64 `json:"saturated_fat_g"`
TransFat *float64 `json:"trans_fat_g"`
Cholesterol *float64 `json:"cholesterol_mg"`
Sodium *float64 `json:"sodium_mg"`
// Whatever else the source record stated, as name → {unit, value}. The
// 5 Star record carries `{"Salt": {"unit":"g","value":0.268}}`. Carried
// through rather than filtered: it is label text, and this code is not the
// authority on what belongs on a food label.
Extended map[string]struct {
Unit string `json:"unit"`
Value float64 `json:"value"`
} `json:"extended_nutrients"`
}
// Panel maps the source record onto what the app renders.
//
// Order is the order a label prints: energy, the macros, then what the source
// added. Only fields the service actually stated — a table of dashes is worse
// than a short table, and the service genuinely omits things (`added_sugar_g`
// is null on the 5 Star record).
//
// Returns nil when nothing was stated, so "no panel" and "an empty panel" stay
// different answers.
func (s *NutritionSource) Panel() *models.NutritionPanel {
if s == nil {
return nil
}
rows := []struct {
name string
value *float64
unit string
}{
{"Energy", s.Calories, "kcal"},
{"Protein", s.Protein, "g"},
{"Carbohydrate", s.Carbohydrates, "g"},
{"Total Sugars", s.TotalSugar, "g"},
{"Added Sugars", s.AddedSugar, "g"},
{"Dietary Fibre", s.DietaryFiber, "g"},
{"Total Fat", s.TotalFat, "g"},
{"Saturated Fat", s.SaturatedFat, "g"},
{"Trans Fat", s.TransFat, "g"},
{"Cholesterol", s.Cholesterol, "mg"},
{"Sodium", s.Sodium, "mg"},
}
items := make([]models.NutritionItem, 0, len(rows)+len(s.Extended))
for _, row := range rows {
if row.value == nil {
continue
}
items = append(items, models.NutritionItem{
Name: row.name, Value: *row.value, Unit: row.unit,
})
}
// Sorted, because a Go map has no order and a nutrition panel that
// reshuffles between two requests for the same product looks broken.
for _, name := range sortedKeys(s.Extended) {
extra := s.Extended[name]
items = append(items, models.NutritionItem{
Name: name, Value: extra.Value, Unit: extra.Unit,
})
}
if len(items) == 0 {
return nil
}
return &models.NutritionPanel{
Per: "100g",
Servingsize: strings.TrimSpace(s.ServingSizeLabel),
Items: items,
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
/*
NutritionService fetches one product's panel.
── Everything here is about not hurting the product screen ─────────────────
This runs inside `getproductbyvariant`, which a shopper is waiting on, and it
calls a service Fiesta does not own. So:
- a short timeout, because a slow third party must not become a slow shop;
- a cache, because nutrition for a packaged product does not change during a
trading day and a variant group asks for several products at once;
- every failure returns nil, never an error. A product page without a
nutrition panel is a product page. A product page that 500s is not.
*/
type NutritionService interface {
// ForProduct returns the nutrition panel and the health score for one
// product. Either may be nil, independently: a product can be scored with
// no figures published, and carry figures with no score.
//
// Never returns an error: see above.
ForProduct(brand, imageID string) ProductNutrition
}
// ProductNutrition is both halves of one product's record.
//
// One value because they come from ONE request. Fetching them separately would
// double the traffic to a third party on a screen a shopper is waiting on, to
// split a record the service returns whole.
type ProductNutrition struct {
Panel *models.NutritionPanel
Health *models.HealthScore
}
// nutritionTimeout is deliberately short.
//
// The alternative is a shopper watching a spinner because somebody else's
// service is having a bad afternoon. A missing panel costs a section of one
// screen; a slow response costs the screen.
const nutritionTimeout = 3 * time.Second
// nutritionTTL is how long a fetched panel is reused.
//
// A packaged product's nutrition does not change during a trading day, and the
// console's own panel uses the same reasoning. Long enough to matter, short
// enough that a correction at the source reaches shoppers the same day.
const nutritionTTL = 6 * time.Hour
type nutritionService struct {
base string
client *http.Client
mu sync.RWMutex
cache map[string]cachedPanel
brands []string
// Zero until the brand list has been read once.
brandsAt time.Time
}
type cachedPanel struct {
value ProductNutrition
at time.Time
}
// NewNutritionService builds the client, or returns nil when no base URL is set.
//
// Nil is a working configuration, like the mailer: a deployment without the
// catalogue-intelligence service still serves every product screen, without a
// nutrition panel on it. `PanelFor` is nil-safe so no caller has to check.
func NewNutritionService(base string) NutritionService {
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" {
return nil
}
return &nutritionService{
base: base,
client: &http.Client{Timeout: nutritionTimeout},
cache: map[string]cachedPanel{},
}
}
func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition {
if s == nil {
return ProductNutrition{}
}
brand, imageID = strings.TrimSpace(brand), strings.TrimSpace(imageID)
if brand == "" || imageID == "" {
// No join key. Sheet-imported products have no image_id, and there is
// nothing to look up rather than something that failed to be found.
return ProductNutrition{}
}
key := brand + "/" + imageID
s.mu.RLock()
hit, ok := s.cache[key]
s.mu.RUnlock()
if ok && time.Since(hit.at) < nutritionTTL {
return hit.value
}
value := s.fetch(brand, imageID)
// A miss is cached too. Most products are not scored yet, and re-asking on
// every tap would mean the least useful answer costing the most requests.
s.mu.Lock()
s.cache[key] = cachedPanel{value: value, at: time.Now()}
s.mu.Unlock()
return value
}
func (s *nutritionService) fetch(brand, imageID string) ProductNutrition {
resolved := s.resolveBrand(brand)
var source NutritionSource
if !s.get(fmt.Sprintf("/nutrition/%s/%s",
url.PathEscape(resolved), url.PathEscape(imageID)), &source) {
return ProductNutrition{}
}
return ProductNutrition{Panel: source.Panel(), Health: source.Health()}
}
/*
resolveBrand turns our spelling of a brand into theirs.
The two catalogues agree on every brand and disagree on how to write it:
ours theirs
patanjali → Patanjali
coca_cola → Coca-Cola underscore becomes a HYPHEN
brooke_bond → Brooke Bond underscore becomes a SPACE
Which separator an underscore becomes cannot be derived — hyphen for Coca-Cola
and Colgate-Palmolive, space for everything else — so the list is fetched and
matched on a normalised form rather than guessed at.
This is not cosmetic. A wrong spelling returns a well-formed record with every
figure null, which is indistinguishable from a product nobody has scored. Get it
wrong and nutrition is simply absent, everywhere, forever, with nothing in any
log to say why.
Our own spelling is returned when theirs is unknown, so a brand they have not
listed still gets a real attempt rather than being dropped.
*/
func (s *nutritionService) resolveBrand(raw string) string {
wanted := normaliseBrand(raw)
if wanted == "" {
return raw
}
s.mu.RLock()
brands, at := s.brands, s.brandsAt
s.mu.RUnlock()
// Refreshed on the same clock as a panel: a brand list changes when the
// scraper learns a new brand, which is not often and not urgently.
if at.IsZero() || time.Since(at) > nutritionTTL {
var payload struct {
Brands []string `json:"brands"`
}
if s.get("/brands", &payload) {
brands = payload.Brands
s.mu.Lock()
s.brands, s.brandsAt = brands, time.Now()
s.mu.Unlock()
}
}
for _, candidate := range brands {
if normaliseBrand(candidate) == wanted {
return candidate
}
}
return raw
}
var brandNoise = regexp.MustCompile(`[^a-z0-9]`)
func normaliseBrand(brand string) string {
return brandNoise.ReplaceAllString(strings.ToLower(strings.TrimSpace(brand)), "")
}
// get reads one JSON document. Reports whether it got one.
//
// Every failure is a false and a log line, never an error returned upward: the
// caller's job is to draw a product screen and it can do that without this.
func (s *nutritionService) get(path string, into any) bool {
response, err := s.client.Get(s.base + path)
if err != nil {
log.Printf("nutrition: %s%s: %v", s.base, path, err)
return false
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
// 404 is a normal answer here — the service does not know this product.
// Logged at the same level as the rest because a sudden wall of them is
// how a renamed path or a moved host gets noticed.
log.Printf("nutrition: %s%s: HTTP %d", s.base, path, response.StatusCode)
return false
}
// Capped: this is an upstream Fiesta does not control, and an unbounded
// read from one is how a memory limit gets found in production.
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
log.Printf("nutrition: %s%s: %v", s.base, path, err)
return false
}
if err := json.Unmarshal(body, into); err != nil {
log.Printf("nutrition: %s%s: malformed response: %v", s.base, path, err)
return false
}
return true
}
/*
Health returns the score, or nil when there is nothing safe to show.
── The three ways this returns nothing ─────────────────────────────────────
- the service has no score for the product, which is most of them;
- the product is not food. The per-product endpoint is not gated for
edibility and has rated insecticide 80/100. See models.IsEdible — the
guard stays even though those records now read "unavailable", because the
tenant this was built for sells soap next to its biscuits;
- there is no record at all.
All three render as "not scored yet", which is honest in every case.
── What it sends that the raw record does not ──────────────────────────────
A band, a label, and — when the match is weak — a caveat and an allergen
warning. Those are judgements, and they already exist in the console. Sending
the raw number instead would mean the app re-deriving them, and two screens
disagreeing about the same product.
*/
func (s *NutritionSource) Health() *models.HealthScore {
if s == nil || s.HealthScore == nil {
return nil
}
if !models.IsEdible(s.Category) {
return nil
}
value := *s.HealthScore
band := models.BandFor(value, s.HealthBand)
// Stated plainly only when the match supports it.
var caveat string
if s.MatchConfidence != nil && *s.MatchConfidence < models.LowConfidence {
caveat = fmt.Sprintf(
"Matched to a reference product with %d%% confidence — treat these figures as a guide.",
int(math.Round(*s.MatchConfidence*100)))
}
allergens := clean(s.Allergens)
return &models.HealthScore{
Score: int(math.Round(value)),
Band: band,
Label: models.BandLabel(band),
Positives: clean(s.PositiveInsights),
Cautions: clean(s.NutritionalCautions),
Diettags: clean(s.DietTags),
Allergens: allergens,
// An empty list is only trustworthy when the match is. Silence must not
// stand in for "contains none".
Allergensunconfirmed: len(allergens) == 0 && caveat != "",
Caveat: caveat,
Source: sourceOf(s),
}
}
func sourceOf(s *NutritionSource) *models.HealthSource {
url := strings.TrimSpace(s.SourceURL)
if url == "" {
return nil
}
label := strings.TrimSpace(s.DataSource)
if label == "" {
label = "source"
}
return &models.HealthSource{Label: label, URL: url}
}
// clean drops blanks, which the service sends for an empty ai_summary and
// others.
func clean(list []string) []string {
kept := make([]string, 0, len(list))
for _, entry := range list {
if trimmed := strings.TrimSpace(entry); trimmed != "" {
kept = append(kept, trimmed)
}
}
if len(kept) == 0 {
return nil
}
return kept
}

View 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)
}
}

View File

@@ -50,10 +50,17 @@ type ProductService interface {
type productService struct {
repo repositories.ProductRepository
catalogueService CatalogueService
// May be nil. A deployment without the catalogue-intelligence service still
// serves every product screen, without a nutrition panel on it.
nutrition NutritionService
}
func NewProductService(repo repositories.ProductRepository, catalogueService CatalogueService) ProductService {
return &productService{repo: repo, catalogueService: catalogueService}
func NewProductService(
repo repositories.ProductRepository,
catalogueService CatalogueService,
nutrition NutritionService,
) ProductService {
return &productService{repo: repo, catalogueService: catalogueService, nutrition: nutrition}
}
func (s *productService) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) {
@@ -251,7 +258,7 @@ func (s *productService) GetProductByVariant(tenantid, variantid, locationid, pr
// `productstock` on each row is what the app disables on. A single ungrouped
// product comes back as a one-member group, so the app has one code path:
// count the rows, and skip the picker when there is only one.
decorateNutrition(result)
decorateNutrition(s.nutrition, result)
return result, nil
}
@@ -375,19 +382,6 @@ func catalogueFactsOf(p *models.CatalogueProduct) map[string]any {
putList("highlights", p.Highlights)
putList("nutrients", p.Nutrients)
// The structured panel, snapshotted like everything else here.
//
// Kept BESIDE `nutrients` rather than instead of it. The lines are what the
// console has always shown and what a re-scrape can still change; the panel
// is what the app renders. Dropping either would break a reader that exists.
//
// `Nutrition` is already the resolved one — structured where the catalogue
// has it, derived from the lines where it does not — so a product imported
// today carries a panel whether or not the agent team has reached its brand.
if p.Nutrition.HasValues() {
facts["nutrition"] = p.Nutrition
}
return facts
}
@@ -634,61 +628,34 @@ func (s *productService) UnpublishProduct(tenantID, productID int) (int, error)
}
/*
decorateNutrition unpacks each product's nutrition panel for the app.
decorateNutrition attaches each product's nutrition panel.
── Why the endpoint does this and the query does not ───────────────────────
The panel lives inside `cataloguefacts`, a jsonb blob the import writes to hold
everything the catalogue knew that `products` has no column for. Reading it in
SQL would mean a jsonb path expression inside a query that already carries four
correlated subqueries, and it would have to be repeated in every read that ever
wants nutrition. Unpacking it once, here, keeps the query about the shelf.
The figures are not in Fiesta's database. They live in the catalogue-
intelligence service, keyed by brand and image_id — the two things a tenant's
product already carries from import — so there is nothing to join to and nothing
for the SQL to select. See nutritionService.go.
── The two shapes it accepts ───────────────────────────────────────────────
── Why a loop of network calls is acceptable here and nowhere else ─────────
`nutrition` is the structured panel and is used wherever it exists. `nutrients`
is the older text[] of display lines — "Energy 350kcal" — which is what every
product on the platform actually carries today, and it is parsed into the same
shape rather than left unreadable by the app.
This endpoint answers one tapped product plus its variant siblings: one row for
most products, a handful for a size family. Never a page of sixty. Each lookup
is cached for six hours and capped at three seconds, and a failure costs that
product its panel and nothing else.
So a product shows figures if ANYTHING is known about it, and the field is
absent only when genuinely nothing is.
── What it will not do ─────────────────────────────────────────────────────
It does not go back to the catalogue DB for a product whose snapshot is empty.
That is a cross-database lookup on a hot mobile endpoint, per product, on a
screen a shopper is waiting for. Products imported before `cataloguefacts`
existed have empty snapshots — measured: all 15 catalogue-imported products on
tenant 1147 — and the fix for those is the backfill in
`scratch/nutritionbackfill`, run once, not a lookup paid for on every tap.
It is deliberately NOT on the browse lists. Sixty products would be sixty
lookups on a screen that shows no nutrition anyway.
*/
func decorateNutrition(products []models.Products) {
func decorateNutrition(nutrition NutritionService, products []models.Products) {
if nutrition == nil {
return
}
for i := range products {
products[i].Nutrition = nutritionFromFacts(products[i].Cataloguefacts)
// One call per product, not two: the panel and the score come from the
// same upstream record.
found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid)
products[i].Nutrition = found.Panel
products[i].Healthscore = found.Health
}
}
// nutritionFromFacts reads a panel out of one product's catalogue snapshot.
//
// Every failure returns nil rather than an error: the product screen is worth
// drawing without a nutrition panel, and a malformed blob on one row must not
// fail the response for the variant group it belongs to.
func nutritionFromFacts(raw string) *models.NutritionPanel {
if strings.TrimSpace(raw) == "" {
return nil
}
var facts struct {
Nutrition *models.NutritionPanel `json:"nutrition"`
Nutrients []string `json:"nutrients"`
}
if err := json.Unmarshal([]byte(raw), &facts); err != nil {
return nil
}
if facts.Nutrition.HasValues() {
return facts.Nutrition
}
return models.NutritionFromLines(facts.Nutrients)
}

View File

@@ -170,7 +170,7 @@ func indexOf(calls []string, want string) int {
// showed the stock climbing and the app never listed the product.
func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135,
@@ -200,7 +200,7 @@ func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
// a freshly created row reading "outofstock" until the NEXT delivery.
func TestTheShelfIsCreatedBeforeAvailabilityIsDerived(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
if err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 25, Stocktype: "in",
@@ -225,7 +225,7 @@ func TestTheShelfIsCreatedBeforeAvailabilityIsDerived(t *testing.T) {
// correctly derived as outofstock — not be silently dropped.
func TestAnOutwardMovementAlsoShelves(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
if err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 3, Stocktype: "out",
@@ -245,7 +245,7 @@ func TestReimportingCorrectsAnUncategorisedProduct(t *testing.T) {
repo.existing = &models.Products{Productid: 7086, Tenantid: 1135, Categoryid: 0}
svc := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 42, Brand: "cadbury", ProductName: "Cadbury Dairy Milk 100g"},
})
}, nil)
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1135, Locationid: 1170, Brand: "cadbury", Catalogueid: 42,
@@ -272,7 +272,7 @@ func TestReimportWithoutACategoryLeavesTheExistingOneAlone(t *testing.T) {
repo.existing = &models.Products{Productid: 7043, Tenantid: 1135, Categoryid: 2}
svc := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 9, Brand: "generic", ProductName: "Apple"},
})
}, nil)
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1135, Locationid: 1170, Brand: "generic", Catalogueid: 9,
@@ -383,7 +383,7 @@ func (f *fakeProductRepo) GetProducts(params models.ProductFilter) ([]models.Pro
// hits and asserts on what a shopper would be shown.
func TestTheBrowseEndpointOffersOnlyStockedProducts(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
CategoryID: 2, TenantID: 1135, LocationID: 1170,
@@ -434,7 +434,7 @@ func TestOrderingAnUngroupedProductAsksByProductId(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 7086, Productname: "Cadbury Dairy Milk 100g", Productstock: 25},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1135, 0, 1170, 7086)
if err != nil {
@@ -455,7 +455,7 @@ func TestOrderingAGroupedProductOffersEveryVariant(t *testing.T) {
{Productid: 6995, Productname: "Strawberries 250g", Productstock: 4},
{Productid: 6996, Productname: "Strawberries 500g", Productstock: 9},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1087, 44, 1097, 0)
if err != nil {
@@ -478,7 +478,7 @@ func TestTheProductScreenStillShowsAnEmptyProduct(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 7085, Productname: "Cadbury 5 Star 18g", Productstock: 0},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1135, 0, 1170, 7085)
if err != nil {
@@ -502,7 +502,7 @@ func TestTheWholeFamilyIsShownIncludingEmptyOnes(t *testing.T) {
{Productid: 6994, Productname: "Pineapple", Productstock: 50},
{Productid: 6989, Productname: "Jammu Apple", Productstock: 0},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
got, err := svc.GetProductByVariant(1087, 36, 1097, 7014)
if err != nil {
@@ -526,7 +526,7 @@ func TestTheWholeFamilyIsShownIncludingEmptyOnes(t *testing.T) {
// still does not. The two endpoints deliberately differ.
func TestBrowseStillHidesWhatTheShopDoesNotHave(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
CategoryID: 2, TenantID: 1135, LocationID: 1170,
@@ -565,7 +565,7 @@ func TestImportPublishesWhatItPriced(t *testing.T) {
ID: 1, Brand: "kohinoor", ProductName: "Kohinoor Charminar Rice 5kg",
ImageID: "kohinoor_kohinoor_charminar_rice_5kg",
},
})
}, nil)
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1147, Locationid: 1185, Brand: "kohinoor", Catalogueid: 1,
@@ -592,7 +592,7 @@ func TestImportPublishesAfterTheRowsExist(t *testing.T) {
repo := newFakeRepo()
service := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 1, Brand: "colin", ProductName: "Colin Glass Cleaner 500ml"},
})
}, nil)
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1147, Locationid: 1185, Brand: "colin", Catalogueid: 1,
@@ -789,7 +789,7 @@ func TestPricingFilterDoesNotDisturbTheCallersSlice(t *testing.T) {
func TestCreateProductReturnsTheIdTheDatabaseAssigned(t *testing.T) {
repo := &fakeProductRepo{}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
created, err := svc.CreateProduct(models.Products{
Tenantid: 9001,
@@ -818,7 +818,7 @@ func TestCreateProductGoesThroughTheOneCreatePath(t *testing.T) {
// discarded the id. Only one remains, and this is what pins that: a second
// path would have to be added here to be used at all.
repo := &fakeProductRepo{}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
if _, err := svc.CreateProduct(models.Products{Tenantid: 9001}); err != nil {
t.Fatalf("CreateProduct: %v", err)
@@ -834,7 +834,7 @@ func TestAFailedCreateReturnsNoProduct(t *testing.T) {
// product with a zero id would be worse than an error — it would send a
// price and a stock movement to product 0.
repo := &fakeProductRepo{createErr: errors.New("duplicate key")}
svc := NewProductService(repo, &fakeCatalogueService{})
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
created, err := svc.CreateProduct(models.Products{Tenantid: 9001, Productsku: "DUP"})
if err == nil {