health score
This commit is contained in:
292
services/healthScore_test.go
Normal file
292
services/healthScore_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
468
services/nutritionService.go
Normal file
468
services/nutritionService.go
Normal 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user