202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
)
|
|
|
|
/*
|
|
Whether a shop shows a product's health score.
|
|
|
|
The score comes from a third party matching a reference product by name, often
|
|
at under 60% confidence, and the figures it is derived from are sometimes wrong
|
|
in ways a shopkeeper can see at a glance — one live record reports under 1mg of
|
|
sodium per 100g for salted crisps. So a merchant can take the rating off their
|
|
own shelf.
|
|
|
|
Two rules run through all of this:
|
|
|
|
- it hides the SCORE and never the nutrition. The figures are what the label
|
|
states; the score is somebody's judgement of them, and a merchant disputing
|
|
the judgement is not disputing the grams.
|
|
- silence means yes. Every product imported before this existed, and every
|
|
caller that does not know about it, must keep showing what they show today.
|
|
*/
|
|
|
|
func withScore(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/brands" {
|
|
_, _ = w.Write([]byte(`{"brands":["Balaji"]}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(balajiJSON))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
func TestAShopThatShowsTheScoreGetsBoth(t *testing.T) {
|
|
rows := []models.Products{{
|
|
Productid: 7101, Productbrand: "balaji", Imageid: "balaji_wafers_135g",
|
|
Showhealthscore: true,
|
|
}}
|
|
decorateNutrition(NewNutritionService(withScore(t).URL), rows)
|
|
|
|
if rows[0].Healthscore == nil {
|
|
t.Fatal("the score was withheld from a shop that shows it")
|
|
}
|
|
if !rows[0].Nutrition.HasValues() {
|
|
t.Fatal("the figures went missing")
|
|
}
|
|
}
|
|
|
|
func TestTurningTheScoreOffLeavesTheFiguresAlone(t *testing.T) {
|
|
// The whole shape of this feature. A merchant saying "that rating does not
|
|
// describe what I sell" has said nothing about the protein content.
|
|
rows := []models.Products{{
|
|
Productid: 7101, Productbrand: "balaji", Imageid: "balaji_wafers_135g",
|
|
Showhealthscore: false,
|
|
}}
|
|
decorateNutrition(NewNutritionService(withScore(t).URL), rows)
|
|
|
|
if rows[0].Healthscore != nil {
|
|
t.Fatalf("the score was shown by a shop that turned it off: %+v", rows[0].Healthscore)
|
|
}
|
|
if !rows[0].Nutrition.HasValues() {
|
|
t.Fatal("turning off the score also removed the nutrition figures")
|
|
}
|
|
}
|
|
|
|
func TestTheHiddenScoreIsAbsentAndNotNull(t *testing.T) {
|
|
// Same contract as the rest of this endpoint: a missing key means "nothing
|
|
// to show", and the app already reads it that way. `"healthscore": null`
|
|
// would be a new shape for a client that is already written.
|
|
row := models.Products{Productid: 7101, Showhealthscore: false}
|
|
|
|
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)
|
|
}
|
|
if _, present := out["healthscore"]; present {
|
|
t.Fatalf("a hidden score was sent as a key: %s", encoded)
|
|
}
|
|
}
|
|
|
|
func TestTwoShopsSellingTheSameProductDecideSeparately(t *testing.T) {
|
|
// The flag is on the tenant's product row, not the catalogue, so one shop
|
|
// hiding a rating cannot take it off anybody else's shelf.
|
|
rows := []models.Products{
|
|
{Productid: 7101, Productbrand: "balaji", Imageid: "balaji_wafers_135g", Showhealthscore: true},
|
|
{Productid: 8202, Productbrand: "balaji", Imageid: "balaji_wafers_135g", Showhealthscore: false},
|
|
}
|
|
decorateNutrition(NewNutritionService(withScore(t).URL), rows)
|
|
|
|
if rows[0].Healthscore == nil {
|
|
t.Error("the shop that shows it lost its score")
|
|
}
|
|
if rows[1].Healthscore != nil {
|
|
t.Error("the shop that hid it got one anyway")
|
|
}
|
|
}
|
|
|
|
/* ── Silence means yes ───────────────────────────────────────────────────── */
|
|
|
|
func TestAnImportThatSaysNothingKeepsTheScore(t *testing.T) {
|
|
// Every console that predates this field omits it. Reading that as a
|
|
// deliberate "no" would strip the score from every import made by an older
|
|
// build — a change nobody asked for, arriving as a deploy.
|
|
var req models.ImportCatalogueProductRequest
|
|
if err := json.Unmarshal([]byte(`{"tenantid":1147,"productid":7101}`), &req); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if req.Showhealthscore != nil {
|
|
t.Fatalf("an absent field was read as a value: %v", *req.Showhealthscore)
|
|
}
|
|
// This is the expression the import uses.
|
|
if show := req.Showhealthscore == nil || *req.Showhealthscore; !show {
|
|
t.Fatal("an import that said nothing turned the score off")
|
|
}
|
|
}
|
|
|
|
func TestAnImportCanSayNoOutright(t *testing.T) {
|
|
// And the other half: `false` has to survive. A bool that cannot be set to
|
|
// false is a toggle that only switches on.
|
|
var req models.ImportCatalogueProductRequest
|
|
if err := json.Unmarshal([]byte(`{"showhealthscore":false}`), &req); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if req.Showhealthscore == nil || *req.Showhealthscore {
|
|
t.Fatalf("an explicit no was lost: %v", req.Showhealthscore)
|
|
}
|
|
if show := req.Showhealthscore == nil || *req.Showhealthscore; show {
|
|
t.Fatal("an explicit no was read as yes")
|
|
}
|
|
}
|
|
|
|
func TestAnImportCanSayYesOutright(t *testing.T) {
|
|
var req models.ImportCatalogueProductRequest
|
|
if err := json.Unmarshal([]byte(`{"showhealthscore":true}`), &req); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if req.Showhealthscore == nil || !*req.Showhealthscore {
|
|
t.Fatalf("an explicit yes was lost: %v", req.Showhealthscore)
|
|
}
|
|
}
|
|
|
|
/* ── The write ───────────────────────────────────────────────────────────── */
|
|
|
|
type showScoreRepo struct {
|
|
repositories.ProductRepository
|
|
tenantID, productID int
|
|
show bool
|
|
calls int
|
|
}
|
|
|
|
func (r *showScoreRepo) SetShowHealthScore(tenantID, productID int, show bool) error {
|
|
r.calls++
|
|
r.tenantID, r.productID, r.show = tenantID, productID, show
|
|
return nil
|
|
}
|
|
|
|
func TestTurningItOffReachesTheRepositoryAsFalse(t *testing.T) {
|
|
// GORM's struct update skips zero values, so `false` is the value most
|
|
// likely to be silently dropped on its way to the database — which would
|
|
// read to a merchant as "it does not save".
|
|
repo := &showScoreRepo{}
|
|
service := &productService{repo: repo}
|
|
|
|
if err := service.SetShowHealthScore(1147, 7101, false); err != nil {
|
|
t.Fatalf("SetShowHealthScore: %v", err)
|
|
}
|
|
if repo.calls != 1 || repo.tenantID != 1147 || repo.productID != 7101 {
|
|
t.Fatalf("wrong call: %+v", repo)
|
|
}
|
|
if repo.show {
|
|
t.Fatal("false arrived as true")
|
|
}
|
|
}
|
|
|
|
func TestAWriteWithNoTenantIsRefusedBeforeItReachesTheDatabase(t *testing.T) {
|
|
// A tenantid of 0 in an UPDATE's WHERE clause matches nothing here, but the
|
|
// habit of letting one through is how an unscoped write eventually ships.
|
|
repo := &showScoreRepo{}
|
|
service := &productService{repo: repo}
|
|
|
|
if err := service.SetShowHealthScore(0, 7101, false); err == nil {
|
|
t.Fatal("an unscoped write was accepted")
|
|
}
|
|
if repo.calls != 0 {
|
|
t.Fatal("it reached the repository anyway")
|
|
}
|
|
}
|