diff --git a/.env b/.env index ca148d6..d06bc7c 100644 --- a/.env +++ b/.env @@ -87,3 +87,8 @@ MAIL_PASSWORD= MAIL_FROM=care@nearledaily.com MAIL_FROM_NAME=Nearle MAIL_CONSOLE_URL=https://app.nearledaily.com + +# ── Nutrition ─────────────────────────────────────────────────────────────── +# The catalogue-intelligence host behind the health score card. The customer +# app product screen reads its nutrition panel from here. Unset means no panel. +NUTRITION_BASE=https://mcp.nearle.ai.in/api diff --git a/.env.example b/.env.example index e78750b..c0d4f67 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,20 @@ EMBEDDING_DIMENSIONS=0 # ── Geocoding ─────────────────────────────────────────────────────────────── # Google Geocoding when set; OpenStreetMap's Nominatim otherwise. GEOCODER_API_KEY= +# ── Nutrition ─────────────────────────────────────────────────────────────── +# +# The catalogue-intelligence service — the same host the console reads its +# health score card from. The customer app product screen gets its nutrition +# panel from here, through `getproductbyvariant`. +# +# Read server-side rather than by the app: the brand-spelling resolution below +# would otherwise have to be reimplemented in the app, and a wrong spelling +# returns a well-formed record with every figure null — indistinguishable from +# a product nobody has scored. +# +# Unset means product screens carry no nutrition panel and nothing else changes. +NUTRITION_BASE=https://mcp.nearle.ai.in/api + # ── Email ─────────────────────────────────────────────────────────────────── # # Sending the first-password invitation a newly onboarded merchant receives. diff --git a/docs/NUTRITION_API.md b/docs/NUTRITION_API.md new file mode 100644 index 0000000..3402a64 --- /dev/null +++ b/docs/NUTRITION_API.md @@ -0,0 +1,115 @@ +# Nutrition and health score — the app contract + +`GET /live/api/v1/mob/products/getproductbyvariant?tenantid=&productid=&variantid=` + +Each product in `details[]` may now carry two extra keys. Both come from the +catalogue-intelligence service (`mcp.nearle.ai.in`) — the same records behind the +health score card in the console — fetched server-side, so the app needs no +second host, no second failure mode, and no copy of the rules below. + +--- + +## nutrition + +```json +"nutrition": { + "per": "100g", + "servingsize": "1 mini (11 g)", + "items": [ + { "name": "Energy", "value": 545, "unit": "kcal" }, + { "name": "Protein", "value": 7.5, "unit": "g" } + ] +} +``` + +- **Absent when unknown.** Not `null`, not `{}`. A missing key means "we do not + know", never "this food has no nutrition". +- `items` is never empty when `nutrition` is present. +- `per` is `"100g"` for everything the service returns today. `servingsize` is + often absent — show the basis only when it is there. +- `value` may be a decimal. `unit` is free text and may be absent. +- Rows appear only when the service stated them. A null field is omitted; a + stated zero is kept, because "no fibre" is a fact and a dash is not. + +## healthscore + +```json +"healthscore": { + "score": 65, + "band": "good", + "label": "Healthy", + "positives": ["Good source of protein (7.5 g per 100 g)."], + "cautions": ["High in saturated fat (14.4 g per 100 g)."], + "diettags": ["High Fiber", "Vegetarian"], + "allergens": [], + "allergensunconfirmed": true, + "caveat": "Matched to a reference product with 61% confidence — treat these figures as a guide.", + "source": { "label": "openfoodfacts", "url": "https://..." } +} +``` + +- **Absent when there is nothing safe to show** — unscored, not food, or no + record at all. All three read as "not rated yet". +- `band` is one of `excellent` | `good` | `fair` | `poor`, for styling. `label` + is what a shopper reads. Use the label; do not re-derive it. +- `score` is 0–100, already rounded. +- `positives`, `cautions` and `diettags` are sentences the service wrote for a + person. Render as given. + +### Two rules the app MUST honour + +**`caveat`, when present, has to be on screen.** It means the underlying source +match was weak — most are; the service matches down to 0.32 confidence. A +nutrition table presented as fact on a 61% match is a claim the data does not +support. + +**`allergensunconfirmed: true` means an empty `allergens` list must NOT be +rendered as "contains none".** Say "not confirmed — check the packet". Silence +standing in for "none" is the one failure here that can put somebody in hospital. +A declared allergen is always sent and must always be shown. + +--- + +## Why the judgement is server-side + +The service returns a raw number and, from this endpoint, no band. Deciding which +band, whether the match is strong enough to state plainly, and whether the +product is even food is a set of rules that already exists in the console. Two +implementations would drift and disagree about the same product on two screens. + +The edibility guard is the sharpest of them. The upstream per-product endpoint is +**not** gated for it: on 4 Sep 2026 it rated Godrej Hit insecticide 80/100 with +`data_status: "verified"`, and soap and shampoo both scored 37.5. Those records +now read "unavailable", and the guard stays — this tenant sells soap and +toothpaste beside its biscuits. + +## Coverage today + +Measured 29 Sep 2026 against tenant 1147: **6 of 15 catalogue-linked products +have nutrition**, and fewer have a score. The Patanjali ghee this work started +from has neither. + +Test with **product 7101, Balaji Wafers Simply Salted** — a full panel and a +65/100 score. + +```sh +go run ./scratch/nutritionproof # three real products +go run ./scratch/nutritionproof # any product +``` + +## Known bad data upstream + +Balaji Wafers reports `sodium_mg: 0.967` — under 1 mg per 100 g, for salted +crisps, where 500–900 mg is normal. The `Salt` figure of 0.002 g is wrong the +same way. It looks like a grams/milligrams mix-up at the source. + +Fiesta passes the value through as given rather than scaling it: silently +"correcting" a food label is how wrong data becomes invisible. It will look wrong +in the app until the agent team fixes the unit. + +## Configuration + +`NUTRITION_BASE=https://mcp.nearle.ai.in/api`, in `.env`. Unset means neither key +is ever sent and nothing else changes. Lookups are cached six hours, capped at +three seconds, and every failure costs that product its panel rather than the +response. diff --git a/docs/NUTRITION_DATA.md b/docs/NUTRITION_DATA.md deleted file mode 100644 index 3933db3..0000000 --- a/docs/NUTRITION_DATA.md +++ /dev/null @@ -1,136 +0,0 @@ -# Nutrition data — the contract between the agent team, Fiesta and the app - -The customer app shows a nutrition panel on the product screen. The data comes -from the global catalogue, which the agent team fills. This is what each side -has to produce and can rely on. - ---- - -## 1. What the app receives - -`GET /live/api/v1/mob/products/getproductbyvariant?tenantid=&productid=&variantid=` - -Each product in `details[]` carries: - -```json -"nutrition": { - "per": "100g", - "servingsize": "30g", - "items": [ - { "name": "Energy", "value": 520, "unit": "kcal" }, - { "name": "Protein", "value": 6.5, "unit": "g" }, - { "name": "Total Sugars", "value": 22, "unit": "g" }, - { "name": "Sodium", "value": 310, "unit": "mg" } - ] -} -``` - -Rules the app can build on: - -- **The key is ABSENT when nothing is known.** Not `null`, not `{}`. A missing - key means "we do not know", never "this food has no nutrition". -- **`items` is never empty when `nutrition` is present.** A panel with no rows - is not sent, because an empty box on a product page reads as a claim. -- **`per` and `servingsize` are each optional** and often absent — see §3. Show - the basis only when it is there. Do not default it to `100g`: a wrong basis - makes every figure beneath it a misstatement rather than an unknown. -- **`value` is a number, and may be a decimal.** Saturated fat is 11.5 g as - often as 11 g. -- **`unit` is free text and may be absent.** "Servings per pack 4" has a figure - and no unit. -- **A row may have a `name` and a `value` of 0 with no unit.** That is real - label text with no number in it — "Contains permitted natural colour". Render - the name and leave the figure column blank; do not print `0`. -- Every member of a variant group carries its own panel, so switching from - 500 ml to 1 L does not blank the screen. - -## 2. What the agent team fills - -A `nutrition` column of type **`jsonb`** on each `brand_*` table in the -catalogue database, holding exactly the object above. - -```sql -ALTER TABLE brand_patanjali ADD COLUMN IF NOT EXISTS nutrition jsonb; -``` - -Notes: - -- **The column is optional and Fiesta already handles its absence.** Catalogue - reads discover columns per table and substitute NULL for any that are missing, - so brands can be filled one at a time and a table without the column keeps - working. Nothing has to be co-ordinated with a deploy. -- **Spelling is `servingsize`**, one word, lowercase. It is the shape the app - was written against. -- **Write what the label says.** If the pack states per 100 g, `per` is `"100g"`. - If it states per serving, say so. If it states neither, omit the field rather - than assuming. -- `nutrition` and the older `nutrients` may both exist on a row. Keep both — - the console shows the lines, the app shows the panel, and they are different - readers with different needs. - -## 3. Where today's data comes from, and why it is thinner - -Every catalogue row currently holds nutrition as `nutrients`, a `text[]` of -display lines: - -``` -{"Energy 350kcal", "Protein 7g"} -``` - -Fiesta parses those into the same panel shape when no structured `nutrition` -exists, so the app shows figures for products the agent team has not reached -yet. That derived panel has **no `per` and no `servingsize`** — the strings -never carried them — which is the visible difference between a filled brand and -one still waiting. - -Parsing rules, in `models/nutrition.go`: - -| line | becomes | -|---|---| -| `Energy 350kcal` | `{Energy, 350, kcal}` | -| `Protein: 6.5 g` | `{Protein, 6.5, g}` | -| `Energy - 520 kcal` | `{Energy, 520, kcal}` | -| `Vitamin B12 1.2µg` | `{Vitamin B12, 1.2, µg}` | -| `Servings per pack 4` | `{Servings per pack, 4, ""}` | -| `Contains permitted colour` | `{Contains permitted colour, 0, ""}` — kept whole | -| `100` | dropped — a figure with no label is noise | - -Structured always wins over derived where both exist. - -## 4. How it reaches a shop's product - -The catalogue is the source; a tenant's product is a **snapshot** taken at -import, because the catalogue is re-scraped and rows retire. Chain: - -``` -brand_*.nutrition (agent team) - └─ catalogue read ─ repositories/catalogueRepository.go: nutritionOf - └─ import ─ services/productService.go: catalogueFactsOf - └─ products.cataloguefacts → {"nutrition": {...}, "nutrients": [...]} - └─ endpoint ─ services/productService.go: decorateNutrition -``` - -`getproductbyvariant` does **not** query the catalogue database. That would be a -cross-database lookup per product on a screen a shopper is waiting on. It reads -the snapshot only. - -## 5. Products imported before the snapshot existed - -**This is the one thing that must be done before any of it shows.** - -`cataloguefacts` is empty on every product imported before that column existed — -measured on tenant 1147: 17 products, 15 catalogue-imported, **0 with facts**. -Those products will show no nutrition no matter what the catalogue holds. - -The fix is a one-off backfill, which also restores their FSSAI licence, -highlights and provider list: - -```sh -go run ./scratch/cataloguefactsbackfill # dry run — prints every change -go run ./scratch/cataloguefactsbackfill apply # writes, then prints the undo -``` - -It touches only products with an `imageid` and a NULL `cataloguefacts`, so -re-running it is a no-op rather than a second opinion. Run it **after** the agent -team fills a brand to pick up that brand's structured panels; it is safe to run -repeatedly as more brands are filled. diff --git a/facade/container.go b/facade/container.go index 156863d..496ce5b 100644 --- a/facade/container.go +++ b/facade/container.go @@ -49,7 +49,7 @@ type Facade struct { // it may be nil if catalogue env vars are not configured, in which case // catalogue endpoints will error at query time rather than at startup. // embedder may be nil too: scan-to-order then matches on words alone. -func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig) *Facade { +func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat utils.Chat, agentsDir, assistantWhy string, mailer utils.Mailer, mailCfg config.MailConfig, nutritionBase string) *Facade { // The invitation, built first because two modules need it. // @@ -82,8 +82,14 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder, chat catalogueController := controllers.NewCatalogueController(catalogueService) // Product Module + // + // The nutrition service is the catalogue-intelligence host — the same one + // behind the health score card in the console — read by the product screen + // for its nutrition panel. Nil when NUTRITION_BASE is unset, which serves + // every product screen exactly as before, without a panel. productRepo := repositories.NewProductRepository(db) - productService := services.NewProductService(productRepo, catalogueService) + productService := services.NewProductService( + productRepo, catalogueService, services.NewNutritionService(nutritionBase)) productController := controllers.NewProductController(productService) // Order Module diff --git a/main.go b/main.go index 4080f92..4332049 100644 --- a/main.go +++ b/main.go @@ -462,8 +462,12 @@ func main() { log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address()) } + // NUTRITION_BASE is the catalogue-intelligence host — the same service the + // console reads its health score card from. Unset means product screens + // carry no nutrition panel, and nothing else changes. f := facade.NewFacade(db.DB, db.CatalogueDB, embedder, chat, - os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail) + os.Getenv("ASSISTANT_AGENTS_DIR"), cfg.Assistant.Why(), mailer, cfg.Mail, + os.Getenv("NUTRITION_BASE")) routes.RegisterRoutes(app, f) diff --git a/models/catalogue.go b/models/catalogue.go index 5b96654..cef55c9 100644 --- a/models/catalogue.go +++ b/models/catalogue.go @@ -26,12 +26,9 @@ type CatalogueProduct struct { FSSAILicense string `json:"fssai_license,omitempty"` Highlights PGStringArray `json:"highlights,omitempty"` Nutrients PGStringArray `json:"nutrients,omitempty"` - // The structured panel, when the catalogue carries one. Nil falls back to - // parsing Nutrients above — see NutritionFromLines. - Nutrition *NutritionPanel `json:"nutrition,omitempty"` - SearchQuery string `json:"search_query,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + SearchQuery string `json:"search_query,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // CatalogueBrand describes a brand available in the catalogue DB. diff --git a/models/healthscore.go b/models/healthscore.go new file mode 100644 index 0000000..b62e0b6 --- /dev/null +++ b/models/healthscore.go @@ -0,0 +1,164 @@ +package models + +import "strings" + +/* +The health score, as the customer app renders it. + +The figures come from the catalogue-intelligence service — the same record the +nutrition panel comes from, and the same one behind the health score card in the +console. See services/nutritionService.go. + +── Why the judgement is made here and not in the app ─────────────────────── + +The service returns a raw number and, from this endpoint, no band. Deciding what +that number means — which band, whether the match is sure enough to state +plainly, whether the product is even food — is a set of rules that already exists +in the console. Sending the raw number and letting the app re-derive them would +mean two implementations of the same judgement, drifting apart, disagreeing about +the same product on two screens. The rules travel with the answer instead. +*/ +type HealthScore struct { + // 0–100, rounded. The service sends one decimal and nobody reads it. + Score int `json:"score"` + // "excellent" | "good" | "fair" | "poor" — for styling. + Band string `json:"band"` + // What a shopper reads, rather than what a nutritionist would call it. + Label string `json:"label"` + + // Sentences the service already wrote for a person. Rendered as given. + Positives []string `json:"positives,omitempty"` + Cautions []string `json:"cautions,omitempty"` + Diettags []string `json:"diettags,omitempty"` + + // Declared allergens. A false positive sends somebody to read the packet; a + // false negative sends them to hospital, so a declared one is always shown. + Allergens []string `json:"allergens,omitempty"` + // True when an EMPTY allergen list must NOT be read as "contains none". + // + // The service accepts a source match down to 0.32 confidence, and + // `data_status: "verified"` speaks to the numbers being real, not to the + // record being this product. What must never happen is silence standing in + // for "none" — which is exactly what an empty list rendered as nothing looks + // like. An app MUST say "not confirmed" rather than draw nothing here. + Allergensunconfirmed bool `json:"allergensunconfirmed,omitempty"` + + // Set when the match is not sure enough to state plainly. When present the + // app must show it: a nutrition table presented as fact on a 61% match is a + // claim the data does not support. + Caveat string `json:"caveat,omitempty"` + + // Where the figures came from, for a shopper who wants to check. + Source *HealthSource `json:"source,omitempty"` +} + +type HealthSource struct { + Label string `json:"label"` + URL string `json:"url"` +} + +// LowConfidence is the line below which a match is a guide, not a fact. +// +// The same 0.7 the console uses. Sampling 40 scored products: 37 matched below +// 0.7 and 26 below 0.5, so this fires often — which is the point. +const LowConfidence = 0.7 + +/* +BandFor turns a score into a band. + +The service's own `health_band` wins when it sends one. It does NOT send one +from the per-product endpoint — only from the list — so for this response the +fallback is not an edge case, it is the only path, which makes these thresholds +load-bearing rather than cosmetic. + +They are the SERVICE'S thresholds, not ours. Derived from its output and since +confirmed by that team in writing: + + excellent >= 80 + good 60 – 79.9 + fair 40 – 59.9 confirmed 40, not 50 + poor < 40 + +Delete this fallback once `health_band` is on the per-product response — it is +on their list. Until then, picking our own numbers would be one API disagreeing +with itself depending which endpoint a screen called. +*/ +func BandFor(score float64, sent string) string { + switch strings.ToLower(strings.TrimSpace(sent)) { + case "excellent", "good", "fair", "poor": + return strings.ToLower(strings.TrimSpace(sent)) + } + switch { + case score >= 80: + return "excellent" + case score >= 60: + return "good" + case score >= 40: + return "fair" + default: + return "poor" + } +} + +// BandLabel is what a shopper reads. +func BandLabel(band string) string { + switch band { + case "excellent": + return "Very healthy" + case "good": + return "Healthy" + case "fair": + return "Okay" + default: + return "Less healthy" + } +} + +/* +foodCategoryWords mean "this is food or drink". + +An ALLOWLIST, and the asymmetry of the two failure modes is why. Withholding a +score on real food costs a shopper a badge they never had. Showing one on +something inedible is a different order of mistake, and the service has made it: +measured 4 Sep 2026, `GET /nutrition/Godrej/godrej_hit_spray_1101d017` returned +`health_score: 80.0, data_status: "verified"` — an "excellent" rating for +insecticide. Palmolive soap and Pantene shampoo both scored 37.5 the same way. + +Re-measured 29 Sep 2026: those records now answer `unavailable`, so the purge +their team described has run. The guard stays anyway. It costs nothing when the +data is clean, and the tenant this was built for stocks soap, shampoo and +toothpaste alongside its food. +*/ +var foodCategoryWords = []string{ + "beverage", "drink", "juice", "water", "tea", "coffee", + "chocolate", "candy", "confection", "sweet", "dessert", + "dairy", "milk", "cheese", "butter", "ghee", "curd", "yogurt", + "snack", "biscuit", "cookie", "wafer", "chips", "namkeen", + "atta", "staple", "flour", "rice", "dal", "pulse", "grain", "cereal", + "pasta", "noodle", "bread", "bakery", + "oil", "masala", "spice", "sauce", "pickle", "jam", "honey", + "food", "nutrition", "breakfast", "fruit", "vegetable", "egg", "meat", +} + +// IsEdible reports whether a score is attached to something a person eats. +// +// An unrecognised category is treated as NOT food. On screen that reads as "not +// scored yet", which is honest — we genuinely do not know — and is what most +// products show anyway. +func IsEdible(category string) bool { + value := strings.ToLower(strings.TrimSpace(category)) + if value == "" { + return false + } + // "General" carries soap and household goods alongside anything else the + // scraper could not place. Ambiguous is not good enough for this decision. + if value == "general" { + return false + } + for _, word := range foodCategoryWords { + if strings.Contains(value, word) { + return true + } + } + return false +} diff --git a/models/nutrition.go b/models/nutrition.go index 5ab52ee..0e11ca1 100644 --- a/models/nutrition.go +++ b/models/nutrition.go @@ -1,137 +1,52 @@ package models -import ( - "regexp" - "strconv" - "strings" -) - /* The nutrition panel, as the customer app renders it. -── Why this is a type and not a list of strings ──────────────────────────── - -The catalogue already carries `nutrients`, a text[] of display lines like -"Energy 350kcal". That is enough to print bullets, which is what the console -does with it today, and not enough for an app: it cannot sort by a value, show -a per-serving column beside a per-100g one, or put the unit in a different -style from the number. It also has nowhere to say what the figures are PER, -which is the one piece of context that makes the rest meaningful — 520 kcal is -a fact about a quantity, and without "per 100g" it is a fact about nothing. - -So this is the shape the agent team fills and the app reads. See -docs/NUTRITION_DATA.md for the contract. +The figures come from the catalogue-intelligence service — the same one behind +the health score card in the console — and this is the shape they reach the app +in. See services/nutritionService.go for the fetch and the mapping. ── Why the field names are ugly ──────────────────────────────────────────── `servingsize`, not `serving_size` or `servingSize`. This is the shape the app -developer asked for, and an API is a promise to a client that has already been -written against it. Consistency with the rest of Fiesta — which is itself -inconsistent, `productid` beside `image_id` beside `sku_source` — is worth less -than not breaking the caller. +developer asked for, and an API is a promise to a client already written against +it. Consistency with the rest of Fiesta — itself inconsistent, `productid` +beside `image_id` beside `sku_source` — is worth less than not breaking the +caller. */ type NutritionPanel struct { - // What the figures are measured against: "100g", "100ml", "1 serving". + // What the figures are measured against. "100g" for everything the service + // returns today: its top-level values are per 100g, which is what the + // console's own panel prints beneath them. Per string `json:"per,omitempty"` - // What the pack calls one serving: "30g". Separate from `Per` because a - // label routinely states both, and the app shows them in different places. + // What the pack calls one serving — "1 mini (11 g)". Absent when the + // service did not state one, rather than defaulted: a serving size is a + // claim about the food, and a guessed one is a false claim. Servingsize string `json:"servingsize,omitempty"` - // Never nil when this panel exists — see `HasValues`. An app that receives - // `items: null` has to branch; one that receives `[]` does not, and a panel - // with no rows should not have been sent at all. + // Never nil when this panel exists — see HasValues. An app receiving + // `items: null` has to branch; one receiving `[]` does not, and a panel with + // no rows should not have been sent at all. Items []NutritionItem `json:"items"` } // NutritionItem is one line of the panel. type NutritionItem struct { Name string `json:"name"` - // The figure. A float because saturated fat is 11.5g as often as it is 11g, - // and rounding it to please a type would be changing a label. + // The figure. A float because saturated fat is 18.7g as often as it is 19g, + // and rounding it to please a type would be editing a label. Value float64 `json:"value"` - // "kcal", "g", "mg". Free text on purpose: the label is the authority and a - // closed list here would mean refusing to carry whatever it actually says. + // "kcal", "g", "mg". Free text on purpose: `extended_nutrients` carries its + // own units from the source, and a closed list here would mean refusing to + // carry whatever the label actually says. Unit string `json:"unit,omitempty"` } // HasValues reports whether this panel is worth sending. // -// A panel with no rows is not a panel — it is an empty box on the product page, -// which reads as "this product has no nutrition" rather than "we do not know -// yet". The endpoint omits it instead. +// A panel with no rows is not a panel — it is an empty box on a product page, +// which a shopper reads as "this food has no nutrition" rather than "we do not +// know yet". The endpoint omits it instead. func (p *NutritionPanel) HasValues() bool { return p != nil && len(p.Items) > 0 } - -/* -nutrientLine pulls "Energy 350kcal" apart. - -── Why parsing exists at all ──────────────────────────────────────────────── - -Every catalogue row on the platform today holds nutrition as those display -strings and nothing else. Waiting for the agent team to refill all of them -before the app can show anything would mean shipping a field that is null for -every product, for as long as that takes. - -So a structured panel is used when one exists, and one is derived from the -strings when it does not. The derived panel is strictly worse — it has no `per` -and no serving size, because the strings never carried them — and it is still -the difference between an app screen with values on it and an empty one. - -── What it refuses to do ─────────────────────────────────────────────────── - -A line it cannot read is kept WHOLE as the name, with no value and no unit, -rather than dropped or guessed at. "Contains permitted natural colour" is a -real nutrition line and it has no number in it; binning it would quietly lose -label text, and forcing a 0 into it would state something false about the food. -*/ -var nutrientLine = regexp.MustCompile(`^(.*?)[\s:]*(-?\d+(?:\.\d+)?)\s*([a-zA-Zµ%]*)$`) - -// NutritionFromLines derives a panel from the catalogue's display strings. -// -// Returns nil when nothing usable is found, so a caller can tell "no nutrition" -// from "a panel with no numbers in it". -func NutritionFromLines(lines []string) *NutritionPanel { - items := make([]NutritionItem, 0, len(lines)) - - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - - match := nutrientLine.FindStringSubmatch(line) - if match == nil { - // No number anywhere. Kept as written — see above. - items = append(items, NutritionItem{Name: line}) - continue - } - - name := strings.TrimSpace(strings.Trim(match[1], "-–—:")) - if name == "" { - // The whole line was a number. Nothing sensible to label it with, - // and an unnamed row on a nutrition panel is noise. - continue - } - - value, err := strconv.ParseFloat(match[2], 64) - if err != nil { - items = append(items, NutritionItem{Name: line}) - continue - } - - items = append(items, NutritionItem{ - Name: name, - Value: value, - Unit: strings.TrimSpace(match[3]), - }) - } - - if len(items) == 0 { - return nil - } - // No `per` and no serving size, deliberately left empty rather than guessed - // at. "100g" is the common case and it is not the only one, and a wrong - // basis is worse than an absent one — it makes every figure beneath it a - // misstatement rather than an unknown. - return &NutritionPanel{Items: items} -} diff --git a/models/nutrition_test.go b/models/nutrition_test.go deleted file mode 100644 index 144df66..0000000 --- a/models/nutrition_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package models - -import "testing" - -/* -Reading the catalogue's nutrition strings. - -The catalogue holds nutrition as display lines — "Energy 350kcal" — and the app -needs figures. These are the shapes those lines actually come in, and the ones -they come in when a scrape goes sideways. - -The rule throughout: never invent a number, and never lose label text. A line -that cannot be read is carried whole rather than dropped, because it is -something a manufacturer printed on a packet and this code is not the authority -on what belongs on a food label. -*/ - -func TestAPlainLineBecomesAFigure(t *testing.T) { - panel := NutritionFromLines([]string{"Energy 350kcal"}) - if !panel.HasValues() { - t.Fatal("nothing parsed") - } - got := panel.Items[0] - if got.Name != "Energy" || got.Value != 350 || got.Unit != "kcal" { - t.Fatalf("got %+v", got) - } -} - -func TestTheSeparatorsThatActuallyOccur(t *testing.T) { - // Colons, multi-word names and a space before the unit are all in the wild, - // and each one used to be a whole line lost. - for _, tc := range []struct { - line string - name string - value float64 - unit string - }{ - {"Protein: 6.5 g", "Protein", 6.5, "g"}, - {"Total Sugars 22g", "Total Sugars", 22, "g"}, - {"Saturated Fat 11.5 g", "Saturated Fat", 11.5, "g"}, - {"Sodium 310mg", "Sodium", 310, "mg"}, - {"Energy - 520 kcal", "Energy", 520, "kcal"}, - } { - panel := NutritionFromLines([]string{tc.line}) - if !panel.HasValues() { - t.Errorf("%q parsed to nothing", tc.line) - continue - } - got := panel.Items[0] - if got.Name != tc.name || got.Value != tc.value || got.Unit != tc.unit { - t.Errorf("%q → %+v, want {%s %v %s}", tc.line, got, tc.name, tc.value, tc.unit) - } - } -} - -func TestADecimalSurvives(t *testing.T) { - // Saturated fat is 11.5g as often as 11g. Rounding to please a type would be - // editing a food label. - panel := NutritionFromLines([]string{"Saturated Fat 11.5g"}) - if panel.Items[0].Value != 11.5 { - t.Fatalf("got %v, want 11.5", panel.Items[0].Value) - } -} - -func TestALineWithNoNumberIsKeptWhole(t *testing.T) { - // Real label text. Dropping it loses something a manufacturer printed; - // forcing a 0 into it states something false about the food. - panel := NutritionFromLines([]string{"Contains permitted natural colour"}) - if !panel.HasValues() { - t.Fatal("the line was dropped") - } - got := panel.Items[0] - if got.Name != "Contains permitted natural colour" { - t.Fatalf("the text was mangled: %+v", got) - } - if got.Value != 0 || got.Unit != "" { - t.Fatalf("a figure was invented for a line that had none: %+v", got) - } -} - -func TestAUnitlessFigureKeepsItsNumber(t *testing.T) { - // "Servings per pack 4" has a real number and no unit. - panel := NutritionFromLines([]string{"Servings per pack 4"}) - got := panel.Items[0] - if got.Name != "Servings per pack" || got.Value != 4 || got.Unit != "" { - t.Fatalf("got %+v", got) - } -} - -func TestPercentAndMicrogramsAreUnits(t *testing.T) { - for _, tc := range []struct{ line, unit string }{ - {"Vitamin C 45%", "%"}, - {"Vitamin B12 1.2µg", "µg"}, - } { - panel := NutritionFromLines([]string{tc.line}) - if !panel.HasValues() || panel.Items[0].Unit != tc.unit { - t.Errorf("%q → %+v, want unit %q", tc.line, panel.Items[0], tc.unit) - } - } -} - -func TestBlanksAndBareNumbersAreNotRows(t *testing.T) { - // A blank is nothing. A bare "100" has no label, and an unnamed row on a - // nutrition panel is noise a shopper cannot use. - if panel := NutritionFromLines([]string{"", " ", "100"}); panel != nil { - t.Fatalf("made a panel out of nothing: %+v", panel) - } -} - -func TestNoLinesMeansNoPanel(t *testing.T) { - // nil rather than an empty panel, so a caller can tell "no nutrition known" - // from "a panel that happens to be empty" — the endpoint omits the first. - if NutritionFromLines(nil) != nil { - t.Fatal("an absent panel was reported as present") - } - if NutritionFromLines([]string{}) != nil { - t.Fatal("an absent panel was reported as present") - } -} - -func TestAnEmptyPanelIsNotWorthSending(t *testing.T) { - // `items: []` on a product page renders as an empty box, which reads as - // "this food has no nutrition" rather than "we do not know yet". - var absent *NutritionPanel - if absent.HasValues() { - t.Fatal("nil reported as having values") - } - if (&NutritionPanel{Per: "100g"}).HasValues() { - t.Fatal("a panel with a basis and no rows reported as having values") - } -} - -func TestTheDerivedPanelDoesNotGuessItsBasis(t *testing.T) { - // The strings never carried one. "100g" is the common case and not the only - // one, and a wrong basis makes every figure beneath it a misstatement rather - // than an unknown. - panel := NutritionFromLines([]string{"Energy 350kcal"}) - if panel.Per != "" || panel.Servingsize != "" { - t.Fatalf("invented a basis: per=%q servingsize=%q", panel.Per, panel.Servingsize) - } -} diff --git a/models/product.go b/models/product.go index 95651ab..2e9284d 100644 --- a/models/product.go +++ b/models/product.go @@ -186,6 +186,17 @@ type Products struct { // Set by the service, not the repository — see decorateNutrition. Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"` + // The health score, for the same product screen and from the same record. + // + // `gorm:"-"`, absent when there is nothing safe to show, and independent of + // Nutrition above — a product can be scored with no figures published, and + // carry figures with no score. + // + // WITHHELD on anything that is not food. The upstream per-product endpoint + // is not gated for edibility and has rated insecticide 80/100; see + // models.IsEdible. + Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"` + Productdesc string `json:"productdesc,omitempty"` Productsku string `json:"productsku,omitempty"` Brandid int `json:"brandid,omitempty"` diff --git a/repositories/catalogueRepository.go b/repositories/catalogueRepository.go index aa92ffb..0aae04a 100644 --- a/repositories/catalogueRepository.go +++ b/repositories/catalogueRepository.go @@ -37,12 +37,7 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured // catalogueProductColumns casts the text[] columns to text: GORM's raw // scan-into-struct silently drops slice-kind destination fields, so they // are read as text here and parsed into []string in scanProductRow. -// -// DELIBERATELY WITHOUT `nutrition`. This list is the fallback for when column -// discovery has not run, and it asserts that every column it names exists — so -// naming one that no brand table has yet would fail every read on this path -// rather than cost one field. `nutrition` lives in catalogueOptionalColumns -// below, which is what lets it ship before the agent team creates it. + const catalogueProductColumns = `id, product_name, title, description, category, image_id, size, variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license, highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at` @@ -73,12 +68,6 @@ var catalogueOptionalColumns = []struct{ Name, Present, Absent string }{ {"fssai_license", "fssai_license", "NULL::text AS fssai_license"}, {"highlights", "highlights::text AS highlights", "NULL::text AS highlights"}, {"nutrients", "nutrients::text AS nutrients", "NULL::text AS nutrients"}, - - // The structured panel the agent team fills: per, serving size and typed - // rows. Optional like everything else here, and that is what makes it - // shippable before the column exists — a brand table without it reads NULL - // and falls back to parsing `nutrients` above, rather than going dark. - {"nutrition", "nutrition::text AS nutrition", "NULL::text AS nutrition"}, {"search_query", "search_query", "NULL::text AS search_query"}, {"created_at", "created_at", "NULL::timestamptz AS created_at"}, {"updated_at", "updated_at", "NULL::timestamptz AS updated_at"}, @@ -146,7 +135,6 @@ type catalogueProductRow struct { FSSAILicense string Highlights string Nutrients string - Nutrition string SearchQuery string CreatedAt time.Time UpdatedAt time.Time @@ -230,7 +218,6 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct { FSSAILicense: row.FSSAILicense, Highlights: models.ParsePGArray(row.Highlights), Nutrients: models.ParsePGArray(row.Nutrients), - Nutrition: nutritionOf(row), SearchQuery: row.SearchQuery, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, @@ -741,29 +728,3 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca product := row.toModel(strings.ToLower(brand)) return &product, nil } - -// nutritionOf decides which nutrition a catalogue row actually has. -// -// Two sources, and the structured one wins wherever it exists. The agent team -// fills `nutrition` — a JSON panel with the basis, the serving size and typed -// rows — and that is the one the app wants. Everything already in the catalogue -// has only `nutrients`, a text[] of display lines, so those are parsed into the -// same shape rather than left unusable. -// -// The derived panel is strictly worse: the strings never carried a basis, so it -// has no "per 100g" on it. It is still the difference between a product page -// with figures and an empty one, for every row on the platform today. -// -// A malformed `nutrition` value falls back rather than failing the read. One -// bad JSON blob in one row should cost that row its panel, not take out the -// brand listing it appears in. -func nutritionOf(row catalogueProductRow) *models.NutritionPanel { - if raw := strings.TrimSpace(row.Nutrition); raw != "" && raw != "null" { - var panel models.NutritionPanel - if err := json.Unmarshal([]byte(raw), &panel); err == nil && panel.HasValues() { - return &panel - } - log.Printf("catalogue: product %d has nutrition that could not be read; falling back to its nutrient lines", row.ID) - } - return models.NutritionFromLines(models.ParsePGArray(row.Nutrients)) -} diff --git a/routes/startup_test.go b/routes/startup_test.go index 6c14a6b..6b48e3a 100644 --- a/routes/startup_test.go +++ b/routes/startup_test.go @@ -43,7 +43,7 @@ func testFacade(t *testing.T) *facade.Facade { // No database, no catalogue, no embedder, no model. A deployment with none // of those must still boot and say what it is missing, rather than failing // somewhere the operator cannot see. - return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests", nil, config.MailConfig{}) + return facade.NewFacade(nil, nil, nil, nil, "", "no model in tests", nil, config.MailConfig{}, "") } func TestTheServerCanBeBuilt(t *testing.T) { diff --git a/scratch/cataloguefactsbackfill/main.go b/scratch/cataloguefactsbackfill/main.go index 1e4664a..0a30aa3 100644 --- a/scratch/cataloguefactsbackfill/main.go +++ b/scratch/cataloguefactsbackfill/main.go @@ -64,17 +64,6 @@ var scalarFacts = []string{ var arrayFacts = []string{"providers", "highlights", "nutrients"} -// The structured nutrition panel: a jsonb column the agent team fills, which -// the customer app renders. Its own category because it is neither a scalar -// nor a text[] — stored as a string it would reach the app as an escaped blob -// inside the snapshot instead of an object, and every reader would have to -// decode it twice. -// -// Absent on every brand table today, which costs this fact and nothing else. -// The lines in `nutrients` above are parsed into a panel at read time, so a -// product backfilled before the column exists still shows figures. -var jsonFacts = []string{"nutrition"} - type product struct { Productid int Productbrand string @@ -323,7 +312,7 @@ func factColumnsOf(db *gorm.DB, table string) []string { } var keep []string - for _, c := range append(append(append([]string{}, scalarFacts...), arrayFacts...), jsonFacts...) { + for _, c := range append(append([]string{}, scalarFacts...), arrayFacts...) { if present[c] { keep = append(keep, c) } @@ -343,9 +332,7 @@ func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[str selects := make([]string, 0, len(cols)) for _, c := range cols { - // jsonb and text[] are both cast to text for the same reason: GORM's - // raw scan cannot land either one in a Go value directly. - if isArrayFact(c) || isJSONFact(c) { + if isArrayFact(c) { selects = append(selects, c+"::text AS "+c) continue } @@ -369,21 +356,6 @@ func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[str if text == "" { continue } - if isJSONFact(c) { - // Embedded as an object, not as the text the column scanned to. - // Stored as a string it would reach the app as an escaped blob - // inside the snapshot, and every reader would decode it twice. - // - // Unreadable JSON is skipped rather than stored: an absent panel - // falls back to the nutrient lines at read time, a broken one does - // not. - var panel models.NutritionPanel - if err := json.Unmarshal([]byte(text), &panel); err != nil || !panel.HasValues() { - continue - } - facts[c] = panel - continue - } if isArrayFact(c) { values := models.ParsePGArray(text) kept := make([]string, 0, len(values)) @@ -402,15 +374,6 @@ func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[str return facts, true } -func isJSONFact(name string) bool { - for _, c := range jsonFacts { - if c == name { - return true - } - } - return false -} - func isArrayFact(name string) bool { for _, c := range arrayFacts { if c == name { diff --git a/scratch/nutritionproof/main.go b/scratch/nutritionproof/main.go new file mode 100644 index 0000000..890736a --- /dev/null +++ b/scratch/nutritionproof/main.go @@ -0,0 +1,59 @@ +// Proves the nutrition panel and health score end to end against the LIVE +// catalogue-intelligence service — no database, no Fiesta, just the piece that +// was built. +// +// go run ./scratch/nutritionproof # known products +// go run ./scratch/nutritionproof # any product +// +// Prints what `getproductbyvariant` will put on the product once deployed. +package main + +import ( + "encoding/json" + "fmt" + "os" + + "nearle/models" + "nearle/services" +) + +func main() { + base := os.Getenv("NUTRITION_BASE") + if base == "" { + base = "https://mcp.nearle.ai.in/api" + } + service := services.NewNutritionService(base) + + // Real products from tenant 1147, checked against the live service on + // 29 Sep 2026: one the service has scored, one it has nothing for, and one + // that is not food — which must come back with no score whatever the + // service says, because that endpoint is not gated for edibility. + products := []models.Products{ + {Productid: 7101, Productbrand: "balaji", Imageid: "balaji_balaji_wafers_simply_salted_135g", + Productname: "Balaji Wafers Simply Salted 135g (scored)"}, + {Productid: 7093, Productbrand: "patanjali", Imageid: "patanjali_patanjali_cow_ghee_500ml", + Productname: "Patanjali Cow Ghee 500ml (no data)"}, + {Productid: 7121, Productbrand: "godrej", Imageid: "godrej_godrej_no1_sandal_and_turmeric_soap_100g", + Productname: "Godrej No.1 Soap 100g (not food)"}, + } + if len(os.Args) == 3 { + products = []models.Products{{ + Productbrand: os.Args[1], + Imageid: os.Args[2], + Productname: os.Args[1] + "/" + os.Args[2], + }} + } + + for i := range products { + found := service.ForProduct(products[i].Productbrand, products[i].Imageid) + products[i].Nutrition = found.Panel + products[i].Healthscore = found.Health + + fmt.Printf("\n---- %s ----\n", products[i].Productname) + encoded, _ := json.MarshalIndent(map[string]any{ + "nutrition": products[i].Nutrition, + "healthscore": products[i].Healthscore, + }, "", " ") + fmt.Println(string(encoded)) + } +} diff --git a/services/healthScore_test.go b/services/healthScore_test.go new file mode 100644 index 0000000..df51da7 --- /dev/null +++ b/services/healthScore_test.go @@ -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) + } +} diff --git a/services/nutritionResponse_test.go b/services/nutritionResponse_test.go deleted file mode 100644 index 33b9ca4..0000000 --- a/services/nutritionResponse_test.go +++ /dev/null @@ -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) - } -} diff --git a/services/nutritionService.go b/services/nutritionService.go new file mode 100644 index 0000000..d4e841d --- /dev/null +++ b/services/nutritionService.go @@ -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 +} diff --git a/services/nutritionService_test.go b/services/nutritionService_test.go new file mode 100644 index 0000000..0daf0e6 --- /dev/null +++ b/services/nutritionService_test.go @@ -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) + } +} diff --git a/services/productService.go b/services/productService.go index c9470b0..56c8340 100644 --- a/services/productService.go +++ b/services/productService.go @@ -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) -} diff --git a/services/productVisibility_test.go b/services/productVisibility_test.go index 158e5ec..d9b9e87 100644 --- a/services/productVisibility_test.go +++ b/services/productVisibility_test.go @@ -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 {