nutrition field
This commit is contained in:
148
services/nutritionResponse_test.go
Normal file
148
services/nutritionResponse_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,7 @@ func (s *productService) GetProductByVariant(tenantid, variantid, locationid, pr
|
||||
// `productstock` on each row is what the app disables on. A single ungrouped
|
||||
// product comes back as a one-member group, so the app has one code path:
|
||||
// count the rows, and skip the picker when there is only one.
|
||||
decorateNutrition(result)
|
||||
return result, nil
|
||||
|
||||
}
|
||||
@@ -374,6 +375,19 @@ func catalogueFactsOf(p *models.CatalogueProduct) map[string]any {
|
||||
putList("highlights", p.Highlights)
|
||||
putList("nutrients", p.Nutrients)
|
||||
|
||||
// The structured panel, snapshotted like everything else here.
|
||||
//
|
||||
// Kept BESIDE `nutrients` rather than instead of it. The lines are what the
|
||||
// console has always shown and what a re-scrape can still change; the panel
|
||||
// is what the app renders. Dropping either would break a reader that exists.
|
||||
//
|
||||
// `Nutrition` is already the resolved one — structured where the catalogue
|
||||
// has it, derived from the lines where it does not — so a product imported
|
||||
// today carries a panel whether or not the agent team has reached its brand.
|
||||
if p.Nutrition.HasValues() {
|
||||
facts["nutrition"] = p.Nutrition
|
||||
}
|
||||
|
||||
return facts
|
||||
}
|
||||
|
||||
@@ -618,3 +632,63 @@ func (s *productService) PublishProduct(tenantID, productID int, price, taxPerce
|
||||
func (s *productService) UnpublishProduct(tenantID, productID int) (int, error) {
|
||||
return s.repo.UnpublishProduct(tenantID, productID)
|
||||
}
|
||||
|
||||
/*
|
||||
decorateNutrition unpacks each product's nutrition panel for the app.
|
||||
|
||||
── Why the endpoint does this and the query does not ───────────────────────
|
||||
|
||||
The panel lives inside `cataloguefacts`, a jsonb blob the import writes to hold
|
||||
everything the catalogue knew that `products` has no column for. Reading it in
|
||||
SQL would mean a jsonb path expression inside a query that already carries four
|
||||
correlated subqueries, and it would have to be repeated in every read that ever
|
||||
wants nutrition. Unpacking it once, here, keeps the query about the shelf.
|
||||
|
||||
── The two shapes it accepts ───────────────────────────────────────────────
|
||||
|
||||
`nutrition` is the structured panel and is used wherever it exists. `nutrients`
|
||||
is the older text[] of display lines — "Energy 350kcal" — which is what every
|
||||
product on the platform actually carries today, and it is parsed into the same
|
||||
shape rather than left unreadable by the app.
|
||||
|
||||
So a product shows figures if ANYTHING is known about it, and the field is
|
||||
absent only when genuinely nothing is.
|
||||
|
||||
── What it will not do ─────────────────────────────────────────────────────
|
||||
|
||||
It does not go back to the catalogue DB for a product whose snapshot is empty.
|
||||
That is a cross-database lookup on a hot mobile endpoint, per product, on a
|
||||
screen a shopper is waiting for. Products imported before `cataloguefacts`
|
||||
existed have empty snapshots — measured: all 15 catalogue-imported products on
|
||||
tenant 1147 — and the fix for those is the backfill in
|
||||
`scratch/nutritionbackfill`, run once, not a lookup paid for on every tap.
|
||||
*/
|
||||
func decorateNutrition(products []models.Products) {
|
||||
for i := range products {
|
||||
products[i].Nutrition = nutritionFromFacts(products[i].Cataloguefacts)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user