health score
This commit is contained in:
5
.env
5
.env
@@ -87,3 +87,8 @@ MAIL_PASSWORD=
|
|||||||
MAIL_FROM=care@nearledaily.com
|
MAIL_FROM=care@nearledaily.com
|
||||||
MAIL_FROM_NAME=Nearle
|
MAIL_FROM_NAME=Nearle
|
||||||
MAIL_CONSOLE_URL=https://app.nearledaily.com
|
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
|
||||||
|
|||||||
14
.env.example
14
.env.example
@@ -120,6 +120,20 @@ EMBEDDING_DIMENSIONS=0
|
|||||||
# ── Geocoding ───────────────────────────────────────────────────────────────
|
# ── Geocoding ───────────────────────────────────────────────────────────────
|
||||||
# Google Geocoding when set; OpenStreetMap's Nominatim otherwise.
|
# Google Geocoding when set; OpenStreetMap's Nominatim otherwise.
|
||||||
GEOCODER_API_KEY=
|
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 ───────────────────────────────────────────────────────────────────
|
# ── Email ───────────────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Sending the first-password invitation a newly onboarded merchant receives.
|
# Sending the first-password invitation a newly onboarded merchant receives.
|
||||||
|
|||||||
115
docs/NUTRITION_API.md
Normal file
115
docs/NUTRITION_API.md
Normal file
@@ -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 <brand> <image_id> # 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.
|
||||||
@@ -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.
|
|
||||||
@@ -49,7 +49,7 @@ type Facade struct {
|
|||||||
// it may be nil if catalogue env vars are not configured, in which case
|
// 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.
|
// catalogue endpoints will error at query time rather than at startup.
|
||||||
// embedder may be nil too: scan-to-order then matches on words alone.
|
// 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.
|
// 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)
|
catalogueController := controllers.NewCatalogueController(catalogueService)
|
||||||
|
|
||||||
// Product Module
|
// 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)
|
productRepo := repositories.NewProductRepository(db)
|
||||||
productService := services.NewProductService(productRepo, catalogueService)
|
productService := services.NewProductService(
|
||||||
|
productRepo, catalogueService, services.NewNutritionService(nutritionBase))
|
||||||
productController := controllers.NewProductController(productService)
|
productController := controllers.NewProductController(productService)
|
||||||
|
|
||||||
// Order Module
|
// Order Module
|
||||||
|
|||||||
6
main.go
6
main.go
@@ -462,8 +462,12 @@ func main() {
|
|||||||
log.Printf("mail: sending as %s via %s", cfg.Mail.FromAddress, cfg.Mail.Address())
|
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,
|
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)
|
routes.RegisterRoutes(app, f)
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,9 @@ type CatalogueProduct struct {
|
|||||||
FSSAILicense string `json:"fssai_license,omitempty"`
|
FSSAILicense string `json:"fssai_license,omitempty"`
|
||||||
Highlights PGStringArray `json:"highlights,omitempty"`
|
Highlights PGStringArray `json:"highlights,omitempty"`
|
||||||
Nutrients PGStringArray `json:"nutrients,omitempty"`
|
Nutrients PGStringArray `json:"nutrients,omitempty"`
|
||||||
// The structured panel, when the catalogue carries one. Nil falls back to
|
SearchQuery string `json:"search_query,omitempty"`
|
||||||
// parsing Nutrients above — see NutritionFromLines.
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
Nutrition *NutritionPanel `json:"nutrition,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.
|
// CatalogueBrand describes a brand available in the catalogue DB.
|
||||||
|
|||||||
164
models/healthscore.go
Normal file
164
models/healthscore.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,137 +1,52 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The nutrition panel, as the customer app renders it.
|
The nutrition panel, as the customer app renders it.
|
||||||
|
|
||||||
── Why this is a type and not a list of strings ────────────────────────────
|
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
|
||||||
The catalogue already carries `nutrients`, a text[] of display lines like
|
in. See services/nutritionService.go for the fetch and the mapping.
|
||||||
"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.
|
|
||||||
|
|
||||||
── Why the field names are ugly ────────────────────────────────────────────
|
── Why the field names are ugly ────────────────────────────────────────────
|
||||||
|
|
||||||
`servingsize`, not `serving_size` or `servingSize`. This is the shape the app
|
`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
|
developer asked for, and an API is a promise to a client already written against
|
||||||
written against it. Consistency with the rest of Fiesta — which is itself
|
it. Consistency with the rest of Fiesta — itself inconsistent, `productid`
|
||||||
inconsistent, `productid` beside `image_id` beside `sku_source` — is worth less
|
beside `image_id` beside `sku_source` — is worth less than not breaking the
|
||||||
than not breaking the caller.
|
caller.
|
||||||
*/
|
*/
|
||||||
type NutritionPanel struct {
|
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"`
|
Per string `json:"per,omitempty"`
|
||||||
// What the pack calls one serving: "30g". Separate from `Per` because a
|
// What the pack calls one serving — "1 mini (11 g)". Absent when the
|
||||||
// label routinely states both, and the app shows them in different places.
|
// 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"`
|
Servingsize string `json:"servingsize,omitempty"`
|
||||||
// Never nil when this panel exists — see `HasValues`. An app that receives
|
// Never nil when this panel exists — see HasValues. An app receiving
|
||||||
// `items: null` has to branch; one that receives `[]` does not, and a panel
|
// `items: null` has to branch; one receiving `[]` does not, and a panel with
|
||||||
// with no rows should not have been sent at all.
|
// no rows should not have been sent at all.
|
||||||
Items []NutritionItem `json:"items"`
|
Items []NutritionItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NutritionItem is one line of the panel.
|
// NutritionItem is one line of the panel.
|
||||||
type NutritionItem struct {
|
type NutritionItem struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// The figure. A float because saturated fat is 11.5g as often as it is 11g,
|
// 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 changing a label.
|
// and rounding it to please a type would be editing a label.
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
// "kcal", "g", "mg". Free text on purpose: the label is the authority and a
|
// "kcal", "g", "mg". Free text on purpose: `extended_nutrients` carries its
|
||||||
// closed list here would mean refusing to carry whatever it actually says.
|
// 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"`
|
Unit string `json:"unit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasValues reports whether this panel is worth sending.
|
// 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,
|
// A panel with no rows is not a panel — it is an empty box on a product page,
|
||||||
// which reads as "this product has no nutrition" rather than "we do not know
|
// which a shopper reads as "this food has no nutrition" rather than "we do not
|
||||||
// yet". The endpoint omits it instead.
|
// know yet". The endpoint omits it instead.
|
||||||
func (p *NutritionPanel) HasValues() bool {
|
func (p *NutritionPanel) HasValues() bool {
|
||||||
return p != nil && len(p.Items) > 0
|
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}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -186,6 +186,17 @@ type Products struct {
|
|||||||
// Set by the service, not the repository — see decorateNutrition.
|
// Set by the service, not the repository — see decorateNutrition.
|
||||||
Nutrition *NutritionPanel `json:"nutrition,omitempty" gorm:"-"`
|
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"`
|
Productdesc string `json:"productdesc,omitempty"`
|
||||||
Productsku string `json:"productsku,omitempty"`
|
Productsku string `json:"productsku,omitempty"`
|
||||||
Brandid int `json:"brandid,omitempty"`
|
Brandid int `json:"brandid,omitempty"`
|
||||||
|
|||||||
@@ -37,12 +37,7 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured
|
|||||||
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
// catalogueProductColumns casts the text[] columns to text: GORM's raw
|
||||||
// scan-into-struct silently drops slice-kind destination fields, so they
|
// scan-into-struct silently drops slice-kind destination fields, so they
|
||||||
// are read as text here and parsed into []string in scanProductRow.
|
// 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,
|
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,
|
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`
|
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"},
|
{"fssai_license", "fssai_license", "NULL::text AS fssai_license"},
|
||||||
{"highlights", "highlights::text AS highlights", "NULL::text AS highlights"},
|
{"highlights", "highlights::text AS highlights", "NULL::text AS highlights"},
|
||||||
{"nutrients", "nutrients::text AS nutrients", "NULL::text AS nutrients"},
|
{"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"},
|
{"search_query", "search_query", "NULL::text AS search_query"},
|
||||||
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
|
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
|
||||||
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
|
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
|
||||||
@@ -146,7 +135,6 @@ type catalogueProductRow struct {
|
|||||||
FSSAILicense string
|
FSSAILicense string
|
||||||
Highlights string
|
Highlights string
|
||||||
Nutrients string
|
Nutrients string
|
||||||
Nutrition string
|
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -230,7 +218,6 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
|||||||
FSSAILicense: row.FSSAILicense,
|
FSSAILicense: row.FSSAILicense,
|
||||||
Highlights: models.ParsePGArray(row.Highlights),
|
Highlights: models.ParsePGArray(row.Highlights),
|
||||||
Nutrients: models.ParsePGArray(row.Nutrients),
|
Nutrients: models.ParsePGArray(row.Nutrients),
|
||||||
Nutrition: nutritionOf(row),
|
|
||||||
SearchQuery: row.SearchQuery,
|
SearchQuery: row.SearchQuery,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
@@ -741,29 +728,3 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca
|
|||||||
product := row.toModel(strings.ToLower(brand))
|
product := row.toModel(strings.ToLower(brand))
|
||||||
return &product, nil
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func testFacade(t *testing.T) *facade.Facade {
|
|||||||
// No database, no catalogue, no embedder, no model. A deployment with none
|
// 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
|
// of those must still boot and say what it is missing, rather than failing
|
||||||
// somewhere the operator cannot see.
|
// 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) {
|
func TestTheServerCanBeBuilt(t *testing.T) {
|
||||||
|
|||||||
@@ -64,17 +64,6 @@ var scalarFacts = []string{
|
|||||||
|
|
||||||
var arrayFacts = []string{"providers", "highlights", "nutrients"}
|
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 {
|
type product struct {
|
||||||
Productid int
|
Productid int
|
||||||
Productbrand string
|
Productbrand string
|
||||||
@@ -323,7 +312,7 @@ func factColumnsOf(db *gorm.DB, table string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var keep []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] {
|
if present[c] {
|
||||||
keep = append(keep, 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))
|
selects := make([]string, 0, len(cols))
|
||||||
for _, c := range cols {
|
for _, c := range cols {
|
||||||
// jsonb and text[] are both cast to text for the same reason: GORM's
|
if isArrayFact(c) {
|
||||||
// raw scan cannot land either one in a Go value directly.
|
|
||||||
if isArrayFact(c) || isJSONFact(c) {
|
|
||||||
selects = append(selects, c+"::text AS "+c)
|
selects = append(selects, c+"::text AS "+c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -369,21 +356,6 @@ func factsFor(db *gorm.DB, table string, cols []string, imageID string) (map[str
|
|||||||
if text == "" {
|
if text == "" {
|
||||||
continue
|
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) {
|
if isArrayFact(c) {
|
||||||
values := models.ParsePGArray(text)
|
values := models.ParsePGArray(text)
|
||||||
kept := make([]string, 0, len(values))
|
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
|
return facts, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func isJSONFact(name string) bool {
|
|
||||||
for _, c := range jsonFacts {
|
|
||||||
if c == name {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isArrayFact(name string) bool {
|
func isArrayFact(name string) bool {
|
||||||
for _, c := range arrayFacts {
|
for _, c := range arrayFacts {
|
||||||
if c == name {
|
if c == name {
|
||||||
|
|||||||
59
scratch/nutritionproof/main.go
Normal file
59
scratch/nutritionproof/main.go
Normal file
@@ -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 <brand> <image_id> # 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
292
services/healthScore_test.go
Normal file
292
services/healthScore_test.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
The health score on the product screen.
|
||||||
|
|
||||||
|
Most of this file is about what the score REFUSES to say. The number itself is
|
||||||
|
the service's; the judgements around it — which band, whether the match is sure
|
||||||
|
enough to state plainly, whether the product is even food — are decisions this
|
||||||
|
code makes on a shopper's behalf, and each one has a way of being wrong that
|
||||||
|
matters more than being absent.
|
||||||
|
|
||||||
|
The fixture is the service's own record for Balaji Wafers Simply Salted, read
|
||||||
|
from mcp.nearle.ai.in on 29 Sep 2026: a real score of 65.3 on a real product in
|
||||||
|
a real tenant's catalogue, matched at 0.607 confidence — below the line.
|
||||||
|
*/
|
||||||
|
const balajiJSON = `{
|
||||||
|
"data_status": "verified", "category": "Snacks",
|
||||||
|
"health_score": 65.3, "nutrition_score": 61.0, "match_confidence": 0.607,
|
||||||
|
"positive_insights": ["Good source of fibre (9.3 g per 100 g)."],
|
||||||
|
"nutritional_cautions": ["High in saturated fat (14.4 g per 100 g)."],
|
||||||
|
"diet_tags": ["Vegetarian"], "allergens": [],
|
||||||
|
"data_source": "openfoodfacts", "source_url": "https://world.openfoodfacts.org/product/1",
|
||||||
|
"calories_kcal": 545, "protein_g": 7.5, "total_fat_g": 30.7
|
||||||
|
}`
|
||||||
|
|
||||||
|
func healthOf(t *testing.T, raw string) *models.HealthScore {
|
||||||
|
t.Helper()
|
||||||
|
var source NutritionSource
|
||||||
|
if err := json.Unmarshal([]byte(raw), &source); err != nil {
|
||||||
|
t.Fatalf("fixture: %v", err)
|
||||||
|
}
|
||||||
|
return source.Health()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARealScoreReachesTheApp(t *testing.T) {
|
||||||
|
score := healthOf(t, balajiJSON)
|
||||||
|
if score == nil {
|
||||||
|
t.Fatal("no score from a verified, edible, scored product")
|
||||||
|
}
|
||||||
|
if score.Score != 65 {
|
||||||
|
// Rounded: the service sends 65.3 and nobody reads the decimal.
|
||||||
|
t.Errorf("score = %d, want 65", score.Score)
|
||||||
|
}
|
||||||
|
if score.Band != "good" || score.Label != "Healthy" {
|
||||||
|
t.Errorf("band = %q / %q", score.Band, score.Label)
|
||||||
|
}
|
||||||
|
if len(score.Positives) != 1 || len(score.Cautions) != 1 {
|
||||||
|
t.Errorf("insights lost: %+v / %+v", score.Positives, score.Cautions)
|
||||||
|
}
|
||||||
|
if score.Source == nil || score.Source.Label != "openfoodfacts" {
|
||||||
|
t.Errorf("source lost: %+v", score.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── What it refuses to say ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestSomethingThatIsNotFoodGetsNoScore(t *testing.T) {
|
||||||
|
// The reason this guard exists. Measured 4 Sep 2026, the upstream
|
||||||
|
// per-product endpoint rated Godrej Hit insecticide 80/100 with
|
||||||
|
// data_status "verified"; soap and shampoo both scored 37.5 the same way.
|
||||||
|
// Those records now read "unavailable", and the guard stays: this tenant
|
||||||
|
// sells soap and toothpaste next to its biscuits.
|
||||||
|
for _, category := range []string{"Household", "Personal Care", "Insecticide", "Cosmetics"} {
|
||||||
|
raw := strings.Replace(balajiJSON, `"category": "Snacks"`, `"category": "`+category+`"`, 1)
|
||||||
|
if score := healthOf(t, raw); score != nil {
|
||||||
|
t.Errorf("%s was rated %d/100", category, score.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnknownCategoryIsTreatedAsNotFood(t *testing.T) {
|
||||||
|
// An allowlist, because the two failure modes are not symmetric. Withholding
|
||||||
|
// a badge from real food costs a shopper something they never had; putting
|
||||||
|
// one on bleach is a different order of mistake.
|
||||||
|
for _, category := range []string{`null`, `""`, `"General"`, `"Miscellaneous"`} {
|
||||||
|
raw := strings.Replace(balajiJSON, `"category": "Snacks"`, `"category": `+category, 1)
|
||||||
|
if score := healthOf(t, raw); score != nil {
|
||||||
|
t.Errorf("category %s was scored %d/100", category, score.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnscoredProductGetsNoScore(t *testing.T) {
|
||||||
|
// The common case: the service knows the product and has not rated it.
|
||||||
|
raw := strings.Replace(balajiJSON, `"health_score": 65.3`, `"health_score": null`, 1)
|
||||||
|
if score := healthOf(t, raw); score != nil {
|
||||||
|
t.Fatalf("invented a score: %+v", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Saying how sure it is ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestAWeakMatchIsDeclared(t *testing.T) {
|
||||||
|
// 0.607 is below the 0.7 line. A nutrition table presented as fact on a 61%
|
||||||
|
// match is a claim the data does not support.
|
||||||
|
score := healthOf(t, balajiJSON)
|
||||||
|
if score.Caveat == "" {
|
||||||
|
t.Fatal("a 0.607 match was presented as fact")
|
||||||
|
}
|
||||||
|
if !strings.Contains(score.Caveat, "61%") {
|
||||||
|
t.Errorf("the caveat does not say how sure: %q", score.Caveat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStrongMatchNeedsNoApology(t *testing.T) {
|
||||||
|
raw := strings.Replace(balajiJSON, `"match_confidence": 0.607`, `"match_confidence": 0.94`, 1)
|
||||||
|
if score := healthOf(t, raw); score.Caveat != "" {
|
||||||
|
t.Errorf("apologised for a 94%% match: %q", score.Caveat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEmptyAllergenListOnAWeakMatchIsFlagged(t *testing.T) {
|
||||||
|
// The one that could actually hurt somebody. The service matches sources
|
||||||
|
// down to 0.32 confidence, and "verified" speaks to the numbers being real,
|
||||||
|
// not to the record being THIS product. Silence must not stand in for
|
||||||
|
// "contains none" — the app has to say "not confirmed" rather than draw
|
||||||
|
// nothing.
|
||||||
|
score := healthOf(t, balajiJSON)
|
||||||
|
if !score.Allergensunconfirmed {
|
||||||
|
t.Fatal("an empty allergen list on a 61% match was left to speak for itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADeclaredAllergenIsAlwaysShown(t *testing.T) {
|
||||||
|
// A false positive sends somebody to read the packet. A false negative sends
|
||||||
|
// them to hospital. So a declared allergen survives a weak match.
|
||||||
|
raw := strings.Replace(balajiJSON, `"allergens": []`, `"allergens": ["Milk", "Soy"]`, 1)
|
||||||
|
score := healthOf(t, raw)
|
||||||
|
if len(score.Allergens) != 2 {
|
||||||
|
t.Fatalf("a declared allergen was dropped: %+v", score.Allergens)
|
||||||
|
}
|
||||||
|
if score.Allergensunconfirmed {
|
||||||
|
t.Error("a list with entries in it was flagged as unconfirmed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEmptyListOnAStrongMatchIsNotFlagged(t *testing.T) {
|
||||||
|
raw := strings.Replace(balajiJSON, `"match_confidence": 0.607`, `"match_confidence": 0.94`, 1)
|
||||||
|
if healthOf(t, raw).Allergensunconfirmed {
|
||||||
|
t.Error("flagged an empty list that the match actually supports")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bands ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestTheBandThresholdsAreTheServices(t *testing.T) {
|
||||||
|
// Confirmed by that team in writing. The per-product endpoint sends no
|
||||||
|
// band, so these are the only path — picking our own would be one API
|
||||||
|
// disagreeing with itself depending which endpoint a screen called.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
score float64
|
||||||
|
band string
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{92, "excellent", "Very healthy"},
|
||||||
|
{80, "excellent", "Very healthy"},
|
||||||
|
{79.9, "good", "Healthy"},
|
||||||
|
{60, "good", "Healthy"},
|
||||||
|
{59.9, "fair", "Okay"},
|
||||||
|
{40, "fair", "Okay"},
|
||||||
|
{39.9, "poor", "Less healthy"},
|
||||||
|
{0, "poor", "Less healthy"},
|
||||||
|
} {
|
||||||
|
band := models.BandFor(tc.score, "")
|
||||||
|
if band != tc.band || models.BandLabel(band) != tc.label {
|
||||||
|
t.Errorf("%v → %q/%q, want %q/%q",
|
||||||
|
tc.score, band, models.BandLabel(band), tc.band, tc.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheServicesOwnBandWinsWhenItSendsOne(t *testing.T) {
|
||||||
|
// It does not today, from this endpoint. When it starts, its answer is the
|
||||||
|
// authority and the thresholds above become the fallback they were meant to
|
||||||
|
// be.
|
||||||
|
if got := models.BandFor(20, "excellent"); got != "excellent" {
|
||||||
|
t.Errorf("ignored the service's own band: %q", got)
|
||||||
|
}
|
||||||
|
if got := models.BandFor(20, " GOOD "); got != "good" {
|
||||||
|
t.Errorf("did not normalise the service's band: %q", got)
|
||||||
|
}
|
||||||
|
if got := models.BandFor(85, "nonsense"); got != "excellent" {
|
||||||
|
t.Errorf("trusted a band it does not recognise: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── On the wire ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestTheScoreAndThePanelAreIndependent(t *testing.T) {
|
||||||
|
// A product can be scored with no figures published, and carry figures with
|
||||||
|
// no score. Tying them together would blank one because the other was
|
||||||
|
// missing.
|
||||||
|
noFigures := `{"category":"Snacks","health_score":65.3,"match_confidence":0.9}`
|
||||||
|
var source NutritionSource
|
||||||
|
if err := json.Unmarshal([]byte(noFigures), &source); err != nil {
|
||||||
|
t.Fatalf("fixture: %v", err)
|
||||||
|
}
|
||||||
|
if source.Panel() != nil {
|
||||||
|
t.Error("made a panel with no figures")
|
||||||
|
}
|
||||||
|
if source.Health() == nil {
|
||||||
|
t.Error("withheld a score because there were no figures")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBothHalvesComeFromOneRequest(t *testing.T) {
|
||||||
|
// Splitting a record the service returns whole would double the traffic to
|
||||||
|
// a third party on a screen a shopper is waiting on.
|
||||||
|
var hits int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Balaji"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hits++
|
||||||
|
_, _ = w.Write([]byte(balajiJSON))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
found := NewNutritionService(server.URL).ForProduct("balaji", "balaji_wafers_135g")
|
||||||
|
if hits != 1 {
|
||||||
|
t.Fatalf("made %d requests for one product", hits)
|
||||||
|
}
|
||||||
|
if !found.Panel.HasValues() || found.Health == nil {
|
||||||
|
t.Fatal("one of the two halves was lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWireNamesForTheScore(t *testing.T) {
|
||||||
|
row := models.Products{Productid: 7101}
|
||||||
|
row.Healthscore = healthOf(t, balajiJSON)
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(row)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
health, ok := out["healthscore"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no `healthscore` key: %s", encoded)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"score", "band", "label", "caveat", "allergensunconfirmed"} {
|
||||||
|
if _, ok := health[key]; !ok {
|
||||||
|
t.Errorf("missing %q: %v", key, health)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProductWithNoScoreHasNoKeyAtAll(t *testing.T) {
|
||||||
|
// Not `"healthscore": null`. Most products have none.
|
||||||
|
encoded, _ := json.Marshal(models.Products{Productid: 1})
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(encoded, &out)
|
||||||
|
if _, present := out["healthscore"]; present {
|
||||||
|
t.Fatalf("an absent score was sent as a key: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoapOnTheProductScreenCarriesNoScore(t *testing.T) {
|
||||||
|
// End to end, through the decoration the endpoint actually calls. This is
|
||||||
|
// the failure a shopper would see: a health rating on a bar of soap.
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Godrej"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(strings.Replace(balajiJSON,
|
||||||
|
`"category": "Snacks"`, `"category": "Personal Care"`, 1)))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
rows := []models.Products{{Productid: 7121, Productbrand: "godrej", Imageid: "godrej_soap_100g"}}
|
||||||
|
decorateNutrition(NewNutritionService(server.URL), rows)
|
||||||
|
|
||||||
|
if rows[0].Healthscore != nil {
|
||||||
|
t.Fatalf("a bar of soap was rated %d/100", rows[0].Healthscore.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"nearle/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
What the product screen receives.
|
|
||||||
|
|
||||||
The app renders a nutrition panel off `getproductbyvariant`. These are about the
|
|
||||||
contract that screen is built on — the exact field names, and what happens for
|
|
||||||
the overwhelming majority of products, which have no structured nutrition at all
|
|
||||||
and are not going to for a while.
|
|
||||||
*/
|
|
||||||
|
|
||||||
func TestTheStructuredPanelIsUsedWhereItExists(t *testing.T) {
|
|
||||||
facts := `{"nutrition":{"per":"100g","servingsize":"30g","items":[
|
|
||||||
{"name":"Energy","value":520,"unit":"kcal"},
|
|
||||||
{"name":"Protein","value":6.5,"unit":"g"}]}}`
|
|
||||||
|
|
||||||
panel := nutritionFromFacts(facts)
|
|
||||||
if !panel.HasValues() {
|
|
||||||
t.Fatal("no panel")
|
|
||||||
}
|
|
||||||
if panel.Per != "100g" || panel.Servingsize != "30g" {
|
|
||||||
t.Fatalf("basis lost: per=%q servingsize=%q", panel.Per, panel.Servingsize)
|
|
||||||
}
|
|
||||||
if len(panel.Items) != 2 || panel.Items[1].Value != 6.5 {
|
|
||||||
t.Fatalf("items wrong: %+v", panel.Items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheOldNutrientLinesStillProduceAPanel(t *testing.T) {
|
|
||||||
// This is the case that actually matters on day one: every product on the
|
|
||||||
// platform carries display lines and nothing else. Without this the app
|
|
||||||
// ships a nutrition screen that is empty for every product in every shop.
|
|
||||||
panel := nutritionFromFacts(`{"nutrients":["Energy 350kcal","Protein 7g"]}`)
|
|
||||||
if !panel.HasValues() {
|
|
||||||
t.Fatal("the lines were not used")
|
|
||||||
}
|
|
||||||
if panel.Items[0].Name != "Energy" || panel.Items[0].Value != 350 {
|
|
||||||
t.Fatalf("got %+v", panel.Items[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStructuredBeatsLinesWhenBothArePresent(t *testing.T) {
|
|
||||||
// The import keeps both — the console shows the lines, the app shows the
|
|
||||||
// panel. The structured one is the agent team's work and carries a basis,
|
|
||||||
// so it wins.
|
|
||||||
facts := `{"nutrients":["Energy 350kcal"],
|
|
||||||
"nutrition":{"per":"100g","items":[{"name":"Energy","value":520,"unit":"kcal"}]}}`
|
|
||||||
|
|
||||||
panel := nutritionFromFacts(facts)
|
|
||||||
if panel.Per != "100g" || panel.Items[0].Value != 520 {
|
|
||||||
t.Fatalf("the derived panel won: %+v", panel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNothingKnownMeansNoPanel(t *testing.T) {
|
|
||||||
for _, facts := range []string{"", " ", "{}", `{"highlights":["Crunchy"]}`} {
|
|
||||||
if p := nutritionFromFacts(facts); p != nil {
|
|
||||||
t.Errorf("%q produced a panel: %+v", facts, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAMalformedSnapshotCostsThePanelAndNothingElse(t *testing.T) {
|
|
||||||
// One bad blob must not fail the response for the variant group it is in —
|
|
||||||
// the shopper tapped a product and is owed the screen.
|
|
||||||
if p := nutritionFromFacts(`{"nutrition":{"items":`); p != nil {
|
|
||||||
t.Fatalf("read a panel out of broken JSON: %+v", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPanelWithNoRowsIsNotSent(t *testing.T) {
|
|
||||||
// `items: []` renders as an empty box, which a shopper reads as "this food
|
|
||||||
// has no nutrition" rather than "we do not know".
|
|
||||||
if p := nutritionFromFacts(`{"nutrition":{"per":"100g","items":[]}}`); p != nil {
|
|
||||||
t.Fatalf("sent an empty panel: %+v", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEveryRowInAVariantGroupIsDecorated(t *testing.T) {
|
|
||||||
// A shopper switching from 500ml to 1L must not watch the nutrition vanish.
|
|
||||||
rows := []models.Products{
|
|
||||||
{Productid: 1, Cataloguefacts: `{"nutrients":["Energy 350kcal"]}`},
|
|
||||||
{Productid: 2, Cataloguefacts: `{"nutrition":{"per":"100g","items":[{"name":"Energy","value":700,"unit":"kcal"}]}}`},
|
|
||||||
{Productid: 3},
|
|
||||||
}
|
|
||||||
decorateNutrition(rows)
|
|
||||||
|
|
||||||
if !rows[0].Nutrition.HasValues() || !rows[1].Nutrition.HasValues() {
|
|
||||||
t.Fatal("a sibling was left without its panel")
|
|
||||||
}
|
|
||||||
if rows[2].Nutrition != nil {
|
|
||||||
t.Fatal("invented a panel for a product with no facts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheWireNamesAreTheOnesTheAppAskedFor(t *testing.T) {
|
|
||||||
// An API is a promise to a client already written against it. `servingsize`
|
|
||||||
// is not how the rest of this file would spell it, and it is what was asked
|
|
||||||
// for, which outranks house style.
|
|
||||||
rows := []models.Products{{
|
|
||||||
Productid: 7093,
|
|
||||||
Cataloguefacts: `{"nutrition":{"per":"100g","servingsize":"30g","items":[{"name":"Energy","value":520,"unit":"kcal"}]}}`,
|
|
||||||
}}
|
|
||||||
decorateNutrition(rows)
|
|
||||||
|
|
||||||
encoded, err := json.Marshal(rows[0])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal(encoded, &out); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
nutrition, ok := out["nutrition"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("no `nutrition` key on the product: %s", encoded)
|
|
||||||
}
|
|
||||||
for _, key := range []string{"per", "servingsize", "items"} {
|
|
||||||
if _, ok := nutrition[key]; !ok {
|
|
||||||
t.Errorf("missing %q: %v", key, nutrition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item := nutrition["items"].([]any)[0].(map[string]any)
|
|
||||||
for _, key := range []string{"name", "value", "unit"} {
|
|
||||||
if _, ok := item[key]; !ok {
|
|
||||||
t.Errorf("item missing %q: %v", key, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
|
|
||||||
// Not `"nutrition": null`. Most products have none, and a null on every row
|
|
||||||
// of a mobile response is payload spent saying nothing.
|
|
||||||
encoded, _ := json.Marshal(models.Products{Productid: 1})
|
|
||||||
var out map[string]any
|
|
||||||
_ = json.Unmarshal(encoded, &out)
|
|
||||||
if _, present := out["nutrition"]; present {
|
|
||||||
t.Fatalf("an absent panel was sent as a key: %s", encoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
468
services/nutritionService.go
Normal file
468
services/nutritionService.go
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Nutrition for the customer app's product screen.
|
||||||
|
|
||||||
|
── Where the figures come from ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
`mcp.nearle.ai.in` — the catalogue-intelligence service, the same one that
|
||||||
|
scrapes the global catalogue and backs the health score card in the console.
|
||||||
|
Not Fiesta's own database: nothing on `products` has ever held nutrition, and
|
||||||
|
the service already has it keyed by brand and image_id, which is exactly what a
|
||||||
|
tenant's product carries from import.
|
||||||
|
|
||||||
|
So this reads it rather than copying it. A second store of the same figures is a
|
||||||
|
second thing to keep in step, and the one that drifts is the one on a food label.
|
||||||
|
|
||||||
|
── Why the console does this in the browser and the app cannot ─────────────
|
||||||
|
|
||||||
|
The console calls the service directly from `api/nutrition.ts`. The customer app
|
||||||
|
could too, in principle, and should not have to: it would mean a second base
|
||||||
|
URL, a second failure mode and the brand-spelling problem below reimplemented in
|
||||||
|
whatever the app is written in. The product screen already calls Fiesta, so
|
||||||
|
Fiesta answers the whole question.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// NutritionSource is the shape the catalogue-intelligence service answers with.
|
||||||
|
//
|
||||||
|
// Only the fields the panel needs. The service returns roughly forty, including
|
||||||
|
// scores, insights, allergens and diet tags — all of which belong to the health
|
||||||
|
// score card in the console and none of which the app asked for.
|
||||||
|
type NutritionSource struct {
|
||||||
|
// "verified" when the source record was confirmed, "unavailable" when the
|
||||||
|
// service knows the product and has nothing for it.
|
||||||
|
DataStatus string `json:"data_status"`
|
||||||
|
|
||||||
|
// The health score half of the same record.
|
||||||
|
HealthScore *float64 `json:"health_score"`
|
||||||
|
// Absent from this endpoint — only the list sends it — so the band is
|
||||||
|
// derived. See models.BandFor.
|
||||||
|
HealthBand string `json:"health_band"`
|
||||||
|
// What the service thinks the product IS. The edibility guard reads this,
|
||||||
|
// and it is routinely null, which the guard treats as "not food".
|
||||||
|
Category string `json:"category"`
|
||||||
|
// 0–1. How sure the service is it matched the right source record. Below
|
||||||
|
// models.LowConfidence the figures are a guide, not a fact.
|
||||||
|
MatchConfidence *float64 `json:"match_confidence"`
|
||||||
|
|
||||||
|
PositiveInsights []string `json:"positive_insights"`
|
||||||
|
NutritionalCautions []string `json:"nutritional_cautions"`
|
||||||
|
DietTags []string `json:"diet_tags"`
|
||||||
|
Allergens []string `json:"allergens"`
|
||||||
|
|
||||||
|
DataSource string `json:"data_source"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
|
||||||
|
ServingSizeLabel string `json:"serving_size_label"`
|
||||||
|
|
||||||
|
// Per 100g, every one of them. The service's own insights say so — "6.8 g
|
||||||
|
// per 100 g" — and the console prints "per 100 g" beneath the same numbers.
|
||||||
|
Calories *float64 `json:"calories_kcal"`
|
||||||
|
Protein *float64 `json:"protein_g"`
|
||||||
|
Carbohydrates *float64 `json:"carbohydrates_g"`
|
||||||
|
TotalSugar *float64 `json:"total_sugar_g"`
|
||||||
|
AddedSugar *float64 `json:"added_sugar_g"`
|
||||||
|
DietaryFiber *float64 `json:"dietary_fiber_g"`
|
||||||
|
TotalFat *float64 `json:"total_fat_g"`
|
||||||
|
SaturatedFat *float64 `json:"saturated_fat_g"`
|
||||||
|
TransFat *float64 `json:"trans_fat_g"`
|
||||||
|
Cholesterol *float64 `json:"cholesterol_mg"`
|
||||||
|
Sodium *float64 `json:"sodium_mg"`
|
||||||
|
|
||||||
|
// Whatever else the source record stated, as name → {unit, value}. The
|
||||||
|
// 5 Star record carries `{"Salt": {"unit":"g","value":0.268}}`. Carried
|
||||||
|
// through rather than filtered: it is label text, and this code is not the
|
||||||
|
// authority on what belongs on a food label.
|
||||||
|
Extended map[string]struct {
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
} `json:"extended_nutrients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel maps the source record onto what the app renders.
|
||||||
|
//
|
||||||
|
// Order is the order a label prints: energy, the macros, then what the source
|
||||||
|
// added. Only fields the service actually stated — a table of dashes is worse
|
||||||
|
// than a short table, and the service genuinely omits things (`added_sugar_g`
|
||||||
|
// is null on the 5 Star record).
|
||||||
|
//
|
||||||
|
// Returns nil when nothing was stated, so "no panel" and "an empty panel" stay
|
||||||
|
// different answers.
|
||||||
|
func (s *NutritionSource) Panel() *models.NutritionPanel {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := []struct {
|
||||||
|
name string
|
||||||
|
value *float64
|
||||||
|
unit string
|
||||||
|
}{
|
||||||
|
{"Energy", s.Calories, "kcal"},
|
||||||
|
{"Protein", s.Protein, "g"},
|
||||||
|
{"Carbohydrate", s.Carbohydrates, "g"},
|
||||||
|
{"Total Sugars", s.TotalSugar, "g"},
|
||||||
|
{"Added Sugars", s.AddedSugar, "g"},
|
||||||
|
{"Dietary Fibre", s.DietaryFiber, "g"},
|
||||||
|
{"Total Fat", s.TotalFat, "g"},
|
||||||
|
{"Saturated Fat", s.SaturatedFat, "g"},
|
||||||
|
{"Trans Fat", s.TransFat, "g"},
|
||||||
|
{"Cholesterol", s.Cholesterol, "mg"},
|
||||||
|
{"Sodium", s.Sodium, "mg"},
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]models.NutritionItem, 0, len(rows)+len(s.Extended))
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, models.NutritionItem{
|
||||||
|
Name: row.name, Value: *row.value, Unit: row.unit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorted, because a Go map has no order and a nutrition panel that
|
||||||
|
// reshuffles between two requests for the same product looks broken.
|
||||||
|
for _, name := range sortedKeys(s.Extended) {
|
||||||
|
extra := s.Extended[name]
|
||||||
|
items = append(items, models.NutritionItem{
|
||||||
|
Name: name, Value: extra.Value, Unit: extra.Unit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.NutritionPanel{
|
||||||
|
Per: "100g",
|
||||||
|
Servingsize: strings.TrimSpace(s.ServingSizeLabel),
|
||||||
|
Items: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys[V any](m map[string]V) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
NutritionService fetches one product's panel.
|
||||||
|
|
||||||
|
── Everything here is about not hurting the product screen ─────────────────
|
||||||
|
|
||||||
|
This runs inside `getproductbyvariant`, which a shopper is waiting on, and it
|
||||||
|
calls a service Fiesta does not own. So:
|
||||||
|
|
||||||
|
- a short timeout, because a slow third party must not become a slow shop;
|
||||||
|
- a cache, because nutrition for a packaged product does not change during a
|
||||||
|
trading day and a variant group asks for several products at once;
|
||||||
|
- every failure returns nil, never an error. A product page without a
|
||||||
|
nutrition panel is a product page. A product page that 500s is not.
|
||||||
|
*/
|
||||||
|
type NutritionService interface {
|
||||||
|
// ForProduct returns the nutrition panel and the health score for one
|
||||||
|
// product. Either may be nil, independently: a product can be scored with
|
||||||
|
// no figures published, and carry figures with no score.
|
||||||
|
//
|
||||||
|
// Never returns an error: see above.
|
||||||
|
ForProduct(brand, imageID string) ProductNutrition
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductNutrition is both halves of one product's record.
|
||||||
|
//
|
||||||
|
// One value because they come from ONE request. Fetching them separately would
|
||||||
|
// double the traffic to a third party on a screen a shopper is waiting on, to
|
||||||
|
// split a record the service returns whole.
|
||||||
|
type ProductNutrition struct {
|
||||||
|
Panel *models.NutritionPanel
|
||||||
|
Health *models.HealthScore
|
||||||
|
}
|
||||||
|
|
||||||
|
// nutritionTimeout is deliberately short.
|
||||||
|
//
|
||||||
|
// The alternative is a shopper watching a spinner because somebody else's
|
||||||
|
// service is having a bad afternoon. A missing panel costs a section of one
|
||||||
|
// screen; a slow response costs the screen.
|
||||||
|
const nutritionTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// nutritionTTL is how long a fetched panel is reused.
|
||||||
|
//
|
||||||
|
// A packaged product's nutrition does not change during a trading day, and the
|
||||||
|
// console's own panel uses the same reasoning. Long enough to matter, short
|
||||||
|
// enough that a correction at the source reaches shoppers the same day.
|
||||||
|
const nutritionTTL = 6 * time.Hour
|
||||||
|
|
||||||
|
type nutritionService struct {
|
||||||
|
base string
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]cachedPanel
|
||||||
|
brands []string
|
||||||
|
// Zero until the brand list has been read once.
|
||||||
|
brandsAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedPanel struct {
|
||||||
|
value ProductNutrition
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNutritionService builds the client, or returns nil when no base URL is set.
|
||||||
|
//
|
||||||
|
// Nil is a working configuration, like the mailer: a deployment without the
|
||||||
|
// catalogue-intelligence service still serves every product screen, without a
|
||||||
|
// nutrition panel on it. `PanelFor` is nil-safe so no caller has to check.
|
||||||
|
func NewNutritionService(base string) NutritionService {
|
||||||
|
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||||
|
if base == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &nutritionService{
|
||||||
|
base: base,
|
||||||
|
client: &http.Client{Timeout: nutritionTimeout},
|
||||||
|
cache: map[string]cachedPanel{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition {
|
||||||
|
if s == nil {
|
||||||
|
return ProductNutrition{}
|
||||||
|
}
|
||||||
|
brand, imageID = strings.TrimSpace(brand), strings.TrimSpace(imageID)
|
||||||
|
if brand == "" || imageID == "" {
|
||||||
|
// No join key. Sheet-imported products have no image_id, and there is
|
||||||
|
// nothing to look up rather than something that failed to be found.
|
||||||
|
return ProductNutrition{}
|
||||||
|
}
|
||||||
|
|
||||||
|
key := brand + "/" + imageID
|
||||||
|
s.mu.RLock()
|
||||||
|
hit, ok := s.cache[key]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if ok && time.Since(hit.at) < nutritionTTL {
|
||||||
|
return hit.value
|
||||||
|
}
|
||||||
|
|
||||||
|
value := s.fetch(brand, imageID)
|
||||||
|
|
||||||
|
// A miss is cached too. Most products are not scored yet, and re-asking on
|
||||||
|
// every tap would mean the least useful answer costing the most requests.
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cache[key] = cachedPanel{value: value, at: time.Now()}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *nutritionService) fetch(brand, imageID string) ProductNutrition {
|
||||||
|
resolved := s.resolveBrand(brand)
|
||||||
|
|
||||||
|
var source NutritionSource
|
||||||
|
if !s.get(fmt.Sprintf("/nutrition/%s/%s",
|
||||||
|
url.PathEscape(resolved), url.PathEscape(imageID)), &source) {
|
||||||
|
return ProductNutrition{}
|
||||||
|
}
|
||||||
|
return ProductNutrition{Panel: source.Panel(), Health: source.Health()}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
resolveBrand turns our spelling of a brand into theirs.
|
||||||
|
|
||||||
|
The two catalogues agree on every brand and disagree on how to write it:
|
||||||
|
|
||||||
|
ours theirs
|
||||||
|
patanjali → Patanjali
|
||||||
|
coca_cola → Coca-Cola underscore becomes a HYPHEN
|
||||||
|
brooke_bond → Brooke Bond underscore becomes a SPACE
|
||||||
|
|
||||||
|
Which separator an underscore becomes cannot be derived — hyphen for Coca-Cola
|
||||||
|
and Colgate-Palmolive, space for everything else — so the list is fetched and
|
||||||
|
matched on a normalised form rather than guessed at.
|
||||||
|
|
||||||
|
This is not cosmetic. A wrong spelling returns a well-formed record with every
|
||||||
|
figure null, which is indistinguishable from a product nobody has scored. Get it
|
||||||
|
wrong and nutrition is simply absent, everywhere, forever, with nothing in any
|
||||||
|
log to say why.
|
||||||
|
|
||||||
|
Our own spelling is returned when theirs is unknown, so a brand they have not
|
||||||
|
listed still gets a real attempt rather than being dropped.
|
||||||
|
*/
|
||||||
|
func (s *nutritionService) resolveBrand(raw string) string {
|
||||||
|
wanted := normaliseBrand(raw)
|
||||||
|
if wanted == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
brands, at := s.brands, s.brandsAt
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
// Refreshed on the same clock as a panel: a brand list changes when the
|
||||||
|
// scraper learns a new brand, which is not often and not urgently.
|
||||||
|
if at.IsZero() || time.Since(at) > nutritionTTL {
|
||||||
|
var payload struct {
|
||||||
|
Brands []string `json:"brands"`
|
||||||
|
}
|
||||||
|
if s.get("/brands", &payload) {
|
||||||
|
brands = payload.Brands
|
||||||
|
s.mu.Lock()
|
||||||
|
s.brands, s.brandsAt = brands, time.Now()
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range brands {
|
||||||
|
if normaliseBrand(candidate) == wanted {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
var brandNoise = regexp.MustCompile(`[^a-z0-9]`)
|
||||||
|
|
||||||
|
func normaliseBrand(brand string) string {
|
||||||
|
return brandNoise.ReplaceAllString(strings.ToLower(strings.TrimSpace(brand)), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// get reads one JSON document. Reports whether it got one.
|
||||||
|
//
|
||||||
|
// Every failure is a false and a log line, never an error returned upward: the
|
||||||
|
// caller's job is to draw a product screen and it can do that without this.
|
||||||
|
func (s *nutritionService) get(path string, into any) bool {
|
||||||
|
response, err := s.client.Get(s.base + path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("nutrition: %s%s: %v", s.base, path, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
// 404 is a normal answer here — the service does not know this product.
|
||||||
|
// Logged at the same level as the rest because a sudden wall of them is
|
||||||
|
// how a renamed path or a moved host gets noticed.
|
||||||
|
log.Printf("nutrition: %s%s: HTTP %d", s.base, path, response.StatusCode)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capped: this is an upstream Fiesta does not control, and an unbounded
|
||||||
|
// read from one is how a memory limit gets found in production.
|
||||||
|
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("nutrition: %s%s: %v", s.base, path, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, into); err != nil {
|
||||||
|
log.Printf("nutrition: %s%s: malformed response: %v", s.base, path, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Health returns the score, or nil when there is nothing safe to show.
|
||||||
|
|
||||||
|
── The three ways this returns nothing ─────────────────────────────────────
|
||||||
|
|
||||||
|
- the service has no score for the product, which is most of them;
|
||||||
|
- the product is not food. The per-product endpoint is not gated for
|
||||||
|
edibility and has rated insecticide 80/100. See models.IsEdible — the
|
||||||
|
guard stays even though those records now read "unavailable", because the
|
||||||
|
tenant this was built for sells soap next to its biscuits;
|
||||||
|
- there is no record at all.
|
||||||
|
|
||||||
|
All three render as "not scored yet", which is honest in every case.
|
||||||
|
|
||||||
|
── What it sends that the raw record does not ──────────────────────────────
|
||||||
|
|
||||||
|
A band, a label, and — when the match is weak — a caveat and an allergen
|
||||||
|
warning. Those are judgements, and they already exist in the console. Sending
|
||||||
|
the raw number instead would mean the app re-deriving them, and two screens
|
||||||
|
disagreeing about the same product.
|
||||||
|
*/
|
||||||
|
func (s *NutritionSource) Health() *models.HealthScore {
|
||||||
|
if s == nil || s.HealthScore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !models.IsEdible(s.Category) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value := *s.HealthScore
|
||||||
|
band := models.BandFor(value, s.HealthBand)
|
||||||
|
|
||||||
|
// Stated plainly only when the match supports it.
|
||||||
|
var caveat string
|
||||||
|
if s.MatchConfidence != nil && *s.MatchConfidence < models.LowConfidence {
|
||||||
|
caveat = fmt.Sprintf(
|
||||||
|
"Matched to a reference product with %d%% confidence — treat these figures as a guide.",
|
||||||
|
int(math.Round(*s.MatchConfidence*100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
allergens := clean(s.Allergens)
|
||||||
|
|
||||||
|
return &models.HealthScore{
|
||||||
|
Score: int(math.Round(value)),
|
||||||
|
Band: band,
|
||||||
|
Label: models.BandLabel(band),
|
||||||
|
Positives: clean(s.PositiveInsights),
|
||||||
|
Cautions: clean(s.NutritionalCautions),
|
||||||
|
Diettags: clean(s.DietTags),
|
||||||
|
Allergens: allergens,
|
||||||
|
// An empty list is only trustworthy when the match is. Silence must not
|
||||||
|
// stand in for "contains none".
|
||||||
|
Allergensunconfirmed: len(allergens) == 0 && caveat != "",
|
||||||
|
Caveat: caveat,
|
||||||
|
Source: sourceOf(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceOf(s *NutritionSource) *models.HealthSource {
|
||||||
|
url := strings.TrimSpace(s.SourceURL)
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(s.DataSource)
|
||||||
|
if label == "" {
|
||||||
|
label = "source"
|
||||||
|
}
|
||||||
|
return &models.HealthSource{Label: label, URL: url}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean drops blanks, which the service sends for an empty ai_summary and
|
||||||
|
// others.
|
||||||
|
func clean(list []string) []string {
|
||||||
|
kept := make([]string, 0, len(list))
|
||||||
|
for _, entry := range list {
|
||||||
|
if trimmed := strings.TrimSpace(entry); trimmed != "" {
|
||||||
|
kept = append(kept, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
371
services/nutritionService_test.go
Normal file
371
services/nutritionService_test.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nearle/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
The nutrition panel on the product screen.
|
||||||
|
|
||||||
|
The fixture is the catalogue-intelligence service's own record for Cadbury
|
||||||
|
5 Star, read from mcp.nearle.ai.in on 29 Sep 2026 — including the awkward parts:
|
||||||
|
a null `added_sugar_g`, a `dietary_fiber_g` of exactly 0, and an
|
||||||
|
`extended_nutrients` map carrying its own unit.
|
||||||
|
*/
|
||||||
|
const fiveStarJSON = `{
|
||||||
|
"data_status": "verified",
|
||||||
|
"serving_size_label": "1 mini (11 g)",
|
||||||
|
"serving_size_g": 11,
|
||||||
|
"calories_kcal": 525.0, "protein_g": 6.8, "carbohydrates_g": 62.6,
|
||||||
|
"total_sugar_g": 58.8, "added_sugar_g": null, "dietary_fiber_g": 0.0,
|
||||||
|
"total_fat_g": 27.9, "saturated_fat_g": 18.7, "trans_fat_g": null,
|
||||||
|
"cholesterol_mg": null, "sodium_mg": 107,
|
||||||
|
"extended_nutrients": {"Salt": {"unit": "g", "value": 0.268}}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// The answer for a product the service knows nothing about — which is most of
|
||||||
|
// them, including the Patanjali ghee this work started from.
|
||||||
|
const unavailableJSON = `{
|
||||||
|
"data_status": "unavailable", "serving_size_label": null,
|
||||||
|
"calories_kcal": null, "protein_g": null, "sodium_mg": null,
|
||||||
|
"extended_nutrients": null
|
||||||
|
}`
|
||||||
|
|
||||||
|
func panelOf(t *testing.T, raw string) *models.NutritionPanel {
|
||||||
|
t.Helper()
|
||||||
|
var source NutritionSource
|
||||||
|
if err := json.Unmarshal([]byte(raw), &source); err != nil {
|
||||||
|
t.Fatalf("fixture: %v", err)
|
||||||
|
}
|
||||||
|
return source.Panel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARealRecordBecomesTheAppsPanel(t *testing.T) {
|
||||||
|
panel := panelOf(t, fiveStarJSON)
|
||||||
|
if !panel.HasValues() {
|
||||||
|
t.Fatal("no panel from a verified record")
|
||||||
|
}
|
||||||
|
if panel.Per != "100g" {
|
||||||
|
// The service's top-level figures are per 100g — its own insights say
|
||||||
|
// "6.8 g per 100 g" and the console prints the same basis.
|
||||||
|
t.Errorf("per = %q, want 100g", panel.Per)
|
||||||
|
}
|
||||||
|
if panel.Servingsize != "1 mini (11 g)" {
|
||||||
|
t.Errorf("servingsize = %q", panel.Servingsize)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string][2]any{
|
||||||
|
"Energy": {525.0, "kcal"},
|
||||||
|
"Protein": {6.8, "g"},
|
||||||
|
"Carbohydrate": {62.6, "g"},
|
||||||
|
"Total Sugars": {58.8, "g"},
|
||||||
|
"Dietary Fibre": {0.0, "g"},
|
||||||
|
"Total Fat": {27.9, "g"},
|
||||||
|
"Saturated Fat": {18.7, "g"},
|
||||||
|
"Sodium": {107.0, "mg"},
|
||||||
|
"Salt": {0.268, "g"},
|
||||||
|
}
|
||||||
|
got := map[string][2]any{}
|
||||||
|
for _, item := range panel.Items {
|
||||||
|
got[item.Name] = [2]any{item.Value, item.Unit}
|
||||||
|
}
|
||||||
|
for name, expected := range want {
|
||||||
|
if got[name] != expected {
|
||||||
|
t.Errorf("%s = %v, want %v", name, got[name], expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(panel.Items) != len(want) {
|
||||||
|
t.Errorf("got %d rows, want %d: %+v", len(panel.Items), len(want), panel.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFieldTheServiceDidNotStateIsNotARow(t *testing.T) {
|
||||||
|
// `added_sugar_g` is null on this record. A table of dashes is worse than a
|
||||||
|
// shorter table, and a 0 would claim the product has no added sugar.
|
||||||
|
for _, item := range panelOf(t, fiveStarJSON).Items {
|
||||||
|
if item.Name == "Added Sugars" || item.Name == "Trans Fat" || item.Name == "Cholesterol" {
|
||||||
|
t.Errorf("invented a row the service left null: %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAGenuineZeroIsKept(t *testing.T) {
|
||||||
|
// `dietary_fiber_g` is 0.0, not null. "No fibre" is a fact the label states
|
||||||
|
// and dropping it would lose it — the opposite of the null case above.
|
||||||
|
var found bool
|
||||||
|
for _, item := range panelOf(t, fiveStarJSON).Items {
|
||||||
|
if item.Name == "Dietary Fibre" {
|
||||||
|
found = true
|
||||||
|
if item.Value != 0 {
|
||||||
|
t.Errorf("got %v, want 0", item.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("a stated zero was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnavailableProductGetsNoPanel(t *testing.T) {
|
||||||
|
// The common case today: the service knows the product and has nothing. An
|
||||||
|
// empty panel on the screen reads as "this food has no nutrition".
|
||||||
|
if panel := panelOf(t, unavailableJSON); panel != nil {
|
||||||
|
t.Fatalf("made a panel out of nulls: %+v", panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRowOrderIsStable(t *testing.T) {
|
||||||
|
// `extended_nutrients` is a map, and Go map order is randomised. A panel
|
||||||
|
// that reshuffles between two requests for the same product looks broken.
|
||||||
|
first := panelOf(t, fiveStarJSON)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
next := panelOf(t, fiveStarJSON)
|
||||||
|
for j := range first.Items {
|
||||||
|
if next.Items[j].Name != first.Items[j].Name {
|
||||||
|
t.Fatalf("row %d moved: %s then %s", j, first.Items[j].Name, next.Items[j].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The client ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestTheBrandSpellingIsResolvedBeforeTheLookup(t *testing.T) {
|
||||||
|
// Ours is `patanjali`, theirs is `Patanjali`. A wrong spelling returns a
|
||||||
|
// well-formed record with every figure null — indistinguishable from an
|
||||||
|
// unscored product — so this failing is silent and total.
|
||||||
|
var asked string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Patanjali","Coca-Cola","Brooke Bond"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asked = r.URL.Path
|
||||||
|
_, _ = w.Write([]byte(fiveStarJSON))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewNutritionService(server.URL)
|
||||||
|
if found := service.ForProduct("patanjali", "patanjali_cow_ghee_500ml"); !found.Panel.HasValues() {
|
||||||
|
t.Fatal("no panel")
|
||||||
|
}
|
||||||
|
if asked != "/nutrition/Patanjali/patanjali_cow_ghee_500ml" {
|
||||||
|
t.Fatalf("asked for %q", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnderscoreIsNotGuessedAt(t *testing.T) {
|
||||||
|
// It becomes a hyphen for Coca-Cola and a space for Brooke Bond. Which one
|
||||||
|
// cannot be derived, so the list is matched rather than transformed.
|
||||||
|
var asked string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Coca-Cola","Brooke Bond"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asked = r.URL.Path
|
||||||
|
_, _ = w.Write([]byte(fiveStarJSON))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
NewNutritionService(server.URL).ForProduct("brooke_bond", "bb_tea_250g")
|
||||||
|
if asked != "/nutrition/Brooke Bond/bb_tea_250g" {
|
||||||
|
t.Fatalf("asked for %q", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnknownBrandStillGetsATry(t *testing.T) {
|
||||||
|
// A brand they have not listed yet is attempted with our spelling rather
|
||||||
|
// than dropped — the list is their vocabulary, not a gate.
|
||||||
|
var asked string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Amul"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asked = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
NewNutritionService(server.URL).ForProduct("newbrand", "nb_thing_1kg")
|
||||||
|
if asked != "/nutrition/newbrand/nb_thing_1kg" {
|
||||||
|
t.Fatalf("asked for %q", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProductScreenSurvivesTheServiceBeingDown(t *testing.T) {
|
||||||
|
// The whole reason this returns a panel and never an error. A shopper
|
||||||
|
// tapped a product; they are owed the screen with or without nutrition.
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
server.Close() // refused connections, not merely 500s
|
||||||
|
|
||||||
|
if found := NewNutritionService(server.URL).ForProduct("cadbury", "x"); found.Panel != nil {
|
||||||
|
t.Fatalf("got a panel from a dead service: %+v", found.Panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMalformedResponseIsNotAPanel(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":[]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"calories_kcal":`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if found := NewNutritionService(server.URL).ForProduct("cadbury", "x"); found.Panel != nil {
|
||||||
|
t.Fatalf("read a panel out of broken JSON: %+v", found.Panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSameProductIsNotFetchedTwice(t *testing.T) {
|
||||||
|
// This runs on a screen a shopper is waiting on, once per member of a
|
||||||
|
// variant group. Nutrition for a packaged product does not change during a
|
||||||
|
// trading day.
|
||||||
|
var hits int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Cadbury"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hits++
|
||||||
|
_, _ = w.Write([]byte(fiveStarJSON))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewNutritionService(server.URL)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
service.ForProduct("cadbury", "cadbury_5_star_200g")
|
||||||
|
}
|
||||||
|
if hits != 1 {
|
||||||
|
t.Fatalf("fetched %d times, want 1", hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMissIsCachedToo(t *testing.T) {
|
||||||
|
// Most products are unscored. Re-asking on every tap would mean the least
|
||||||
|
// useful answer costing the most requests.
|
||||||
|
var hits int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Patanjali"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hits++
|
||||||
|
_, _ = w.Write([]byte(unavailableJSON))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewNutritionService(server.URL)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
service.ForProduct("patanjali", "patanjali_cow_ghee_500ml")
|
||||||
|
}
|
||||||
|
if hits != 1 {
|
||||||
|
t.Fatalf("fetched %d times, want 1", hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProductWithNoJoinKeyIsNotLookedUp(t *testing.T) {
|
||||||
|
// Sheet-imported products have no image_id. There is nothing to look up,
|
||||||
|
// which is different from something that failed to be found.
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Error("called the service for a product with no image_id")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewNutritionService(server.URL)
|
||||||
|
service.ForProduct("cadbury", "")
|
||||||
|
service.ForProduct("", "some_image_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoServiceConfiguredIsNotACrash(t *testing.T) {
|
||||||
|
// Nil is a working configuration: a deployment without the service serves
|
||||||
|
// every product screen, without a panel.
|
||||||
|
if NewNutritionService("") != nil {
|
||||||
|
t.Fatal("built a client with no base URL")
|
||||||
|
}
|
||||||
|
rows := []models.Products{{Productid: 1, Productbrand: "cadbury", Imageid: "x"}}
|
||||||
|
decorateNutrition(nil, rows) // must not panic
|
||||||
|
if rows[0].Nutrition != nil {
|
||||||
|
t.Fatal("invented a panel with no service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEverySiblingInAVariantGroupIsDecorated(t *testing.T) {
|
||||||
|
// A shopper switching from 500ml to 1L must not watch the panel vanish.
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/brands" {
|
||||||
|
_, _ = w.Write([]byte(`{"brands":["Cadbury"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(fiveStarJSON))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
rows := []models.Products{
|
||||||
|
{Productid: 1, Productbrand: "cadbury", Imageid: "a"},
|
||||||
|
{Productid: 2, Productbrand: "cadbury", Imageid: "b"},
|
||||||
|
{Productid: 3, Productbrand: "cadbury", Imageid: ""},
|
||||||
|
}
|
||||||
|
decorateNutrition(NewNutritionService(server.URL), rows)
|
||||||
|
|
||||||
|
if !rows[0].Nutrition.HasValues() || !rows[1].Nutrition.HasValues() {
|
||||||
|
t.Fatal("a sibling lost its panel")
|
||||||
|
}
|
||||||
|
if rows[2].Nutrition != nil {
|
||||||
|
t.Fatal("invented a panel for a product with no join key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWireNamesAreTheOnesTheAppAskedFor(t *testing.T) {
|
||||||
|
// An API is a promise to a client already written against it. `servingsize`
|
||||||
|
// is not how the rest of this codebase would spell it, and it is what was
|
||||||
|
// asked for, which outranks house style.
|
||||||
|
rows := []models.Products{{Productid: 7093}}
|
||||||
|
rows[0].Nutrition = panelOf(t, fiveStarJSON)
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(rows[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nutrition, ok := out["nutrition"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no `nutrition` key: %s", encoded)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"per", "servingsize", "items"} {
|
||||||
|
if _, ok := nutrition[key]; !ok {
|
||||||
|
t.Errorf("missing %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item := nutrition["items"].([]any)[0].(map[string]any)
|
||||||
|
for _, key := range []string{"name", "value", "unit"} {
|
||||||
|
if _, ok := item[key]; !ok {
|
||||||
|
t.Errorf("item missing %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
|
||||||
|
// Not `"nutrition": null`. Most products have none, and a null on every row
|
||||||
|
// is payload spent saying nothing.
|
||||||
|
encoded, _ := json.Marshal(models.Products{Productid: 1})
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.Unmarshal(encoded, &out)
|
||||||
|
if _, present := out["nutrition"]; present {
|
||||||
|
t.Fatalf("an absent panel was sent as a key: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,10 +50,17 @@ type ProductService interface {
|
|||||||
type productService struct {
|
type productService struct {
|
||||||
repo repositories.ProductRepository
|
repo repositories.ProductRepository
|
||||||
catalogueService CatalogueService
|
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 {
|
func NewProductService(
|
||||||
return &productService{repo: repo, catalogueService: catalogueService}
|
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) {
|
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
|
// `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:
|
// 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.
|
// count the rows, and skip the picker when there is only one.
|
||||||
decorateNutrition(result)
|
decorateNutrition(s.nutrition, result)
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -375,19 +382,6 @@ func catalogueFactsOf(p *models.CatalogueProduct) map[string]any {
|
|||||||
putList("highlights", p.Highlights)
|
putList("highlights", p.Highlights)
|
||||||
putList("nutrients", p.Nutrients)
|
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
|
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 ───────────────────────
|
── Why the endpoint does this and the query does not ───────────────────────
|
||||||
|
|
||||||
The panel lives inside `cataloguefacts`, a jsonb blob the import writes to hold
|
The figures are not in Fiesta's database. They live in the catalogue-
|
||||||
everything the catalogue knew that `products` has no column for. Reading it in
|
intelligence service, keyed by brand and image_id — the two things a tenant's
|
||||||
SQL would mean a jsonb path expression inside a query that already carries four
|
product already carries from import — so there is nothing to join to and nothing
|
||||||
correlated subqueries, and it would have to be repeated in every read that ever
|
for the SQL to select. See nutritionService.go.
|
||||||
wants nutrition. Unpacking it once, here, keeps the query about the shelf.
|
|
||||||
|
|
||||||
── 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`
|
This endpoint answers one tapped product plus its variant siblings: one row for
|
||||||
is the older text[] of display lines — "Energy 350kcal" — which is what every
|
most products, a handful for a size family. Never a page of sixty. Each lookup
|
||||||
product on the platform actually carries today, and it is parsed into the same
|
is cached for six hours and capped at three seconds, and a failure costs that
|
||||||
shape rather than left unreadable by the app.
|
product its panel and nothing else.
|
||||||
|
|
||||||
So a product shows figures if ANYTHING is known about it, and the field is
|
It is deliberately NOT on the browse lists. Sixty products would be sixty
|
||||||
absent only when genuinely nothing is.
|
lookups on a screen that shows no nutrition anyway.
|
||||||
|
|
||||||
── 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.
|
|
||||||
*/
|
*/
|
||||||
func decorateNutrition(products []models.Products) {
|
func decorateNutrition(nutrition NutritionService, products []models.Products) {
|
||||||
|
if nutrition == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
for i := range products {
|
for i := range products {
|
||||||
products[i].Nutrition = nutritionFromFacts(products[i].Cataloguefacts)
|
// One call per product, not two: the panel and the score come from the
|
||||||
|
// same upstream record.
|
||||||
|
found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid)
|
||||||
|
products[i].Nutrition = found.Panel
|
||||||
|
products[i].Healthscore = found.Health
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nutritionFromFacts reads a panel out of one product's catalogue snapshot.
|
|
||||||
//
|
|
||||||
// Every failure returns nil rather than an error: the product screen is worth
|
|
||||||
// drawing without a nutrition panel, and a malformed blob on one row must not
|
|
||||||
// fail the response for the variant group it belongs to.
|
|
||||||
func nutritionFromFacts(raw string) *models.NutritionPanel {
|
|
||||||
if strings.TrimSpace(raw) == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var facts struct {
|
|
||||||
Nutrition *models.NutritionPanel `json:"nutrition"`
|
|
||||||
Nutrients []string `json:"nutrients"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(raw), &facts); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if facts.Nutrition.HasValues() {
|
|
||||||
return facts.Nutrition
|
|
||||||
}
|
|
||||||
return models.NutritionFromLines(facts.Nutrients)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ func indexOf(calls []string, want string) int {
|
|||||||
// showed the stock climbing and the app never listed the product.
|
// showed the stock climbing and the app never listed the product.
|
||||||
func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
|
func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
err := svc.CreateProductStock([]models.Productstock{{
|
err := svc.CreateProductStock([]models.Productstock{{
|
||||||
Tenantid: 1135,
|
Tenantid: 1135,
|
||||||
@@ -200,7 +200,7 @@ func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
|
|||||||
// a freshly created row reading "outofstock" until the NEXT delivery.
|
// a freshly created row reading "outofstock" until the NEXT delivery.
|
||||||
func TestTheShelfIsCreatedBeforeAvailabilityIsDerived(t *testing.T) {
|
func TestTheShelfIsCreatedBeforeAvailabilityIsDerived(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
if err := svc.CreateProductStock([]models.Productstock{{
|
if err := svc.CreateProductStock([]models.Productstock{{
|
||||||
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 25, Stocktype: "in",
|
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.
|
// correctly derived as outofstock — not be silently dropped.
|
||||||
func TestAnOutwardMovementAlsoShelves(t *testing.T) {
|
func TestAnOutwardMovementAlsoShelves(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
if err := svc.CreateProductStock([]models.Productstock{{
|
if err := svc.CreateProductStock([]models.Productstock{{
|
||||||
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 3, Stocktype: "out",
|
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}
|
repo.existing = &models.Products{Productid: 7086, Tenantid: 1135, Categoryid: 0}
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{
|
svc := NewProductService(repo, &fakeCatalogueService{
|
||||||
product: &models.CatalogueProduct{ID: 42, Brand: "cadbury", ProductName: "Cadbury Dairy Milk 100g"},
|
product: &models.CatalogueProduct{ID: 42, Brand: "cadbury", ProductName: "Cadbury Dairy Milk 100g"},
|
||||||
})
|
}, nil)
|
||||||
|
|
||||||
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
||||||
Tenantid: 1135, Locationid: 1170, Brand: "cadbury", Catalogueid: 42,
|
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}
|
repo.existing = &models.Products{Productid: 7043, Tenantid: 1135, Categoryid: 2}
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{
|
svc := NewProductService(repo, &fakeCatalogueService{
|
||||||
product: &models.CatalogueProduct{ID: 9, Brand: "generic", ProductName: "Apple"},
|
product: &models.CatalogueProduct{ID: 9, Brand: "generic", ProductName: "Apple"},
|
||||||
})
|
}, nil)
|
||||||
|
|
||||||
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
||||||
Tenantid: 1135, Locationid: 1170, Brand: "generic", Catalogueid: 9,
|
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.
|
// hits and asserts on what a shopper would be shown.
|
||||||
func TestTheBrowseEndpointOffersOnlyStockedProducts(t *testing.T) {
|
func TestTheBrowseEndpointOffersOnlyStockedProducts(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
|
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
|
||||||
CategoryID: 2, TenantID: 1135, LocationID: 1170,
|
CategoryID: 2, TenantID: 1135, LocationID: 1170,
|
||||||
@@ -434,7 +434,7 @@ func TestOrderingAnUngroupedProductAsksByProductId(t *testing.T) {
|
|||||||
repo := &variantRepo{rows: []models.Products{
|
repo := &variantRepo{rows: []models.Products{
|
||||||
{Productid: 7086, Productname: "Cadbury Dairy Milk 100g", Productstock: 25},
|
{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)
|
got, err := svc.GetProductByVariant(1135, 0, 1170, 7086)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -455,7 +455,7 @@ func TestOrderingAGroupedProductOffersEveryVariant(t *testing.T) {
|
|||||||
{Productid: 6995, Productname: "Strawberries 250g", Productstock: 4},
|
{Productid: 6995, Productname: "Strawberries 250g", Productstock: 4},
|
||||||
{Productid: 6996, Productname: "Strawberries 500g", Productstock: 9},
|
{Productid: 6996, Productname: "Strawberries 500g", Productstock: 9},
|
||||||
}}
|
}}
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
got, err := svc.GetProductByVariant(1087, 44, 1097, 0)
|
got, err := svc.GetProductByVariant(1087, 44, 1097, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -478,7 +478,7 @@ func TestTheProductScreenStillShowsAnEmptyProduct(t *testing.T) {
|
|||||||
repo := &variantRepo{rows: []models.Products{
|
repo := &variantRepo{rows: []models.Products{
|
||||||
{Productid: 7085, Productname: "Cadbury 5 Star 18g", Productstock: 0},
|
{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)
|
got, err := svc.GetProductByVariant(1135, 0, 1170, 7085)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -502,7 +502,7 @@ func TestTheWholeFamilyIsShownIncludingEmptyOnes(t *testing.T) {
|
|||||||
{Productid: 6994, Productname: "Pineapple", Productstock: 50},
|
{Productid: 6994, Productname: "Pineapple", Productstock: 50},
|
||||||
{Productid: 6989, Productname: "Jammu Apple", Productstock: 0},
|
{Productid: 6989, Productname: "Jammu Apple", Productstock: 0},
|
||||||
}}
|
}}
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
got, err := svc.GetProductByVariant(1087, 36, 1097, 7014)
|
got, err := svc.GetProductByVariant(1087, 36, 1097, 7014)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -526,7 +526,7 @@ func TestTheWholeFamilyIsShownIncludingEmptyOnes(t *testing.T) {
|
|||||||
// still does not. The two endpoints deliberately differ.
|
// still does not. The two endpoints deliberately differ.
|
||||||
func TestBrowseStillHidesWhatTheShopDoesNotHave(t *testing.T) {
|
func TestBrowseStillHidesWhatTheShopDoesNotHave(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
|
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
|
||||||
CategoryID: 2, TenantID: 1135, LocationID: 1170,
|
CategoryID: 2, TenantID: 1135, LocationID: 1170,
|
||||||
@@ -565,7 +565,7 @@ func TestImportPublishesWhatItPriced(t *testing.T) {
|
|||||||
ID: 1, Brand: "kohinoor", ProductName: "Kohinoor Charminar Rice 5kg",
|
ID: 1, Brand: "kohinoor", ProductName: "Kohinoor Charminar Rice 5kg",
|
||||||
ImageID: "kohinoor_kohinoor_charminar_rice_5kg",
|
ImageID: "kohinoor_kohinoor_charminar_rice_5kg",
|
||||||
},
|
},
|
||||||
})
|
}, nil)
|
||||||
|
|
||||||
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
||||||
Tenantid: 1147, Locationid: 1185, Brand: "kohinoor", Catalogueid: 1,
|
Tenantid: 1147, Locationid: 1185, Brand: "kohinoor", Catalogueid: 1,
|
||||||
@@ -592,7 +592,7 @@ func TestImportPublishesAfterTheRowsExist(t *testing.T) {
|
|||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
service := NewProductService(repo, &fakeCatalogueService{
|
service := NewProductService(repo, &fakeCatalogueService{
|
||||||
product: &models.CatalogueProduct{ID: 1, Brand: "colin", ProductName: "Colin Glass Cleaner 500ml"},
|
product: &models.CatalogueProduct{ID: 1, Brand: "colin", ProductName: "Colin Glass Cleaner 500ml"},
|
||||||
})
|
}, nil)
|
||||||
|
|
||||||
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
|
||||||
Tenantid: 1147, Locationid: 1185, Brand: "colin", Catalogueid: 1,
|
Tenantid: 1147, Locationid: 1185, Brand: "colin", Catalogueid: 1,
|
||||||
@@ -789,7 +789,7 @@ func TestPricingFilterDoesNotDisturbTheCallersSlice(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateProductReturnsTheIdTheDatabaseAssigned(t *testing.T) {
|
func TestCreateProductReturnsTheIdTheDatabaseAssigned(t *testing.T) {
|
||||||
repo := &fakeProductRepo{}
|
repo := &fakeProductRepo{}
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
created, err := svc.CreateProduct(models.Products{
|
created, err := svc.CreateProduct(models.Products{
|
||||||
Tenantid: 9001,
|
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
|
// 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.
|
// path would have to be added here to be used at all.
|
||||||
repo := &fakeProductRepo{}
|
repo := &fakeProductRepo{}
|
||||||
svc := NewProductService(repo, &fakeCatalogueService{})
|
svc := NewProductService(repo, &fakeCatalogueService{}, nil)
|
||||||
|
|
||||||
if _, err := svc.CreateProduct(models.Products{Tenantid: 9001}); err != nil {
|
if _, err := svc.CreateProduct(models.Products{Tenantid: 9001}); err != nil {
|
||||||
t.Fatalf("CreateProduct: %v", err)
|
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
|
// product with a zero id would be worse than an error — it would send a
|
||||||
// price and a stock movement to product 0.
|
// price and a stock movement to product 0.
|
||||||
repo := &fakeProductRepo{createErr: errors.New("duplicate key")}
|
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"})
|
created, err := svc.CreateProduct(models.Products{Tenantid: 9001, Productsku: "DUP"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user