api for health score toggle
This commit is contained in:
@@ -1008,3 +1008,44 @@ func (ctl *ProductController) RelinkCatalogue(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report})
|
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": report})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
//
|
||||||
|
// Scoped twice over: `middleware.WebAuth` refuses a request naming a tenant the
|
||||||
|
// session does not own — it reads `tenantid` from the body as well as the query
|
||||||
|
// — and the repository's UPDATE carries the tenant in its WHERE clause. A write
|
||||||
|
// that changes what a shopper sees should not rest on one guard being mounted
|
||||||
|
// correctly.
|
||||||
|
func (ctl *ProductController) SetShowHealthScore(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Productid int `json:"productid"`
|
||||||
|
// A POINTER so a body that forgot the field is refused rather than read
|
||||||
|
// as "turn it off". The whole point of this endpoint is the difference
|
||||||
|
// between the two.
|
||||||
|
Showhealthscore *bool `json:"showhealthscore"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.Showhealthscore == nil {
|
||||||
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"code": http.StatusBadRequest, "status": false,
|
||||||
|
"message": "showhealthscore is required: send true or false.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.productService.SetShowHealthScore(req.Tenantid, req.Productid, *req.Showhealthscore); err != nil {
|
||||||
|
// 409, not 500. "No such product for this business" is a fact the
|
||||||
|
// caller can act on, not a fault in the server.
|
||||||
|
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||||
|
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
20
main.go
20
main.go
@@ -176,6 +176,26 @@ func main() {
|
|||||||
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
|
log.Println("⚠️ could not add products.cataloguefacts, catalogue detail will not survive a re-scrape:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether this shop shows a health score for this product.
|
||||||
|
//
|
||||||
|
// The shopkeeper's call, not ours. The score comes from a third party that
|
||||||
|
// matches a reference product by name — often at under 60% confidence — so a
|
||||||
|
// merchant who knows the packet in front of them may reasonably decide the
|
||||||
|
// rating does not describe what they are selling, and should be able to take
|
||||||
|
// it off their own shelf without taking it off everybody's.
|
||||||
|
//
|
||||||
|
// DEFAULT TRUE, so every product already imported keeps showing exactly what
|
||||||
|
// it shows today. A new column defaulting to false would silently strip the
|
||||||
|
// health score from every shelf on the platform, which is a change nobody
|
||||||
|
// asked for dressed up as a migration.
|
||||||
|
//
|
||||||
|
// Only the score. `nutrition` is unaffected and always sent: the figures are
|
||||||
|
// what the packet says, while the score is somebody's judgement of them.
|
||||||
|
if err := db.DB.Exec(
|
||||||
|
`ALTER TABLE products ADD COLUMN IF NOT EXISTS showhealthscore boolean NOT NULL DEFAULT true`).Error; err != nil {
|
||||||
|
log.Println("⚠️ could not add products.showhealthscore, every product will keep showing its health score:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// When a product became visible to a store, and the only thing that decides
|
// When a product became visible to a store, and the only thing that decides
|
||||||
// whether it is.
|
// whether it is.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -197,6 +197,25 @@ type Products struct {
|
|||||||
// models.IsEdible.
|
// models.IsEdible.
|
||||||
Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"`
|
Healthscore *HealthScore `json:"healthscore,omitempty" gorm:"-"`
|
||||||
|
|
||||||
|
// Whether this shop shows a health score for this product.
|
||||||
|
//
|
||||||
|
// A real column, unlike the two above. The shopkeeper's call: the score
|
||||||
|
// comes from a third party matching a reference product by name, often
|
||||||
|
// under 60% confidence, and a merchant who knows the packet may reasonably
|
||||||
|
// decide the rating does not describe what they sell.
|
||||||
|
//
|
||||||
|
// Defaults true, so every product imported before this column existed keeps
|
||||||
|
// showing what it shows today.
|
||||||
|
//
|
||||||
|
// Gates `Healthscore` and NOTHING else. `Nutrition` is always sent — the
|
||||||
|
// figures are what the packet says, the score is a judgement of them, and a
|
||||||
|
// merchant turning off the judgement is not disputing the grams.
|
||||||
|
//
|
||||||
|
// The PLATFORM console ignores this: Nearle staff see every score on every
|
||||||
|
// product, because the decision being made there is whether the data is good
|
||||||
|
// enough to publish at all.
|
||||||
|
Showhealthscore bool `json:"showhealthscore" gorm:"column:showhealthscore;default:true"`
|
||||||
|
|
||||||
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"`
|
||||||
@@ -492,6 +511,14 @@ type ImportCatalogueProductRequest struct {
|
|||||||
Retailprice float64 `json:"retailprice"`
|
Retailprice float64 `json:"retailprice"`
|
||||||
Productcost float64 `json:"productcost"`
|
Productcost float64 `json:"productcost"`
|
||||||
Taxpercent float64 `json:"taxpercent"`
|
Taxpercent float64 `json:"taxpercent"`
|
||||||
|
|
||||||
|
// Whether this shop will show the product's health score.
|
||||||
|
//
|
||||||
|
// A POINTER so "not sent" and "sent as false" stay different answers. Every
|
||||||
|
// caller that predates this field omits it, and a bare bool would read those
|
||||||
|
// as a deliberate no and strip the score from every import made by an older
|
||||||
|
// console. Nil means "they did not say", which is treated as yes.
|
||||||
|
Showhealthscore *bool `json:"showhealthscore"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Productlocations struct {
|
type Productlocations struct {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type ProductRepository interface {
|
|||||||
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
|
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
|
||||||
CreateProductStock(stocks []models.Productstock) error
|
CreateProductStock(stocks []models.Productstock) error
|
||||||
UpdateProductStatus(productIDs []int, status string) error
|
UpdateProductStatus(productIDs []int, status string) error
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
SetShowHealthScore(tenantID, productID int, show bool) error
|
||||||
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
SyncProductLocationStatus(refs []models.ProductLocationRef) error
|
||||||
EnsureProductLocation(refs []models.ProductLocationRef) error
|
EnsureProductLocation(refs []models.ProductLocationRef) error
|
||||||
UpdateProduct(product models.Products) error
|
UpdateProduct(product models.Products) error
|
||||||
@@ -1725,3 +1727,30 @@ func (r *productRepository) UpdateProductPricing(productid int, retailprice, pro
|
|||||||
"taxpercent": taxpercent,
|
"taxpercent": taxpercent,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
//
|
||||||
|
// `Update` with a single column, deliberately, and not `Updates` with a struct.
|
||||||
|
// GORM's struct update SKIPS zero values, so `showhealthscore: false` would be
|
||||||
|
// silently dropped — the flag could be switched on and never off again, which is
|
||||||
|
// the exact failure a merchant would report as "it does not save".
|
||||||
|
//
|
||||||
|
// Scoped by tenant as well as product. `middleware.WebAuth` already refuses a
|
||||||
|
// request naming a tenant the session does not own, so this is the second lock
|
||||||
|
// rather than the first — but a write that changes what a shopper sees should
|
||||||
|
// not rest on one check being correctly mounted.
|
||||||
|
func (r *productRepository) SetShowHealthScore(tenantID, productID int, show bool) error {
|
||||||
|
result := r.db.Table("products").
|
||||||
|
Where("productid = ? AND tenantid = ?", productID, tenantID).
|
||||||
|
Update("showhealthscore", show)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
// Either no such product, or one belonging to another business. Both
|
||||||
|
// are the same answer to the caller, and neither should look like it
|
||||||
|
// worked.
|
||||||
|
return fmt.Errorf("product %d was not found for this business", productID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
|
||||||
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
|
||||||
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
|
||||||
|
|
||||||
|
// Whether this shop shows a product's health score.
|
||||||
|
//
|
||||||
|
// On `/v1/web` only. The merchant decides for their own shelf, so it needs
|
||||||
|
// the session that says which shelf is theirs — and the mobile group has no
|
||||||
|
// guard at all, which would make this "anyone can turn any shop's health
|
||||||
|
// scores off".
|
||||||
|
products.Put("/showhealthscore", f.ProductController.SetShowHealthScore)
|
||||||
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
|
||||||
|
|
||||||
// Repairing catalogue links. A dry run unless `apply=true` — see the handler,
|
// Repairing catalogue links. A dry run unless `apply=true` — see the handler,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type ProductService interface {
|
|||||||
GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error)
|
GetSaleTemplate(tenantID, locationID int) (*models.SaleTemplate, error)
|
||||||
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
FetchFilteredProducts(categoryID, subcategoryID, productID, applocationID, tenantID, locationID int, keyword, productStatus, approve string, pageno, pagesize int) ([]models.Tenantproducts, error)
|
||||||
GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error)
|
GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error)
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
SetShowHealthScore(tenantID, productID int, show bool) error
|
||||||
GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error)
|
GetProductsBySubcategory(params models.ProductFilter) (map[string]interface{}, error)
|
||||||
UpdateProductLocation(input models.Productlocations) error
|
UpdateProductLocation(input models.Productlocations) error
|
||||||
CreateProductLocation(input []models.Productlocations) error
|
CreateProductLocation(input []models.Productlocations) error
|
||||||
@@ -493,6 +495,10 @@ func (s *productService) ImportCatalogueProduct(reqs []models.ImportCataloguePro
|
|||||||
Retailprice: retail,
|
Retailprice: retail,
|
||||||
Taxpercent: req.Taxpercent,
|
Taxpercent: req.Taxpercent,
|
||||||
Approve: 1,
|
Approve: 1,
|
||||||
|
// Nil means the caller did not say, which is yes — see the
|
||||||
|
// field. An older console that knows nothing about this must not
|
||||||
|
// have its imports read as a deliberate no.
|
||||||
|
Showhealthscore: req.Showhealthscore == nil || *req.Showhealthscore,
|
||||||
}
|
}
|
||||||
// Everything the snapshot has no column for, kept as the catalogue
|
// Everything the snapshot has no column for, kept as the catalogue
|
||||||
// stated it.
|
// stated it.
|
||||||
@@ -656,6 +662,31 @@ func decorateNutrition(nutrition NutritionService, products []models.Products) {
|
|||||||
// same upstream record.
|
// same upstream record.
|
||||||
found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid)
|
found := nutrition.ForProduct(products[i].Productbrand, products[i].Imageid)
|
||||||
products[i].Nutrition = found.Panel
|
products[i].Nutrition = found.Panel
|
||||||
products[i].Healthscore = found.Health
|
|
||||||
|
// The score is the shop's to show or not. The figures are not.
|
||||||
|
//
|
||||||
|
// A merchant turning this off is saying "that rating does not describe
|
||||||
|
// what I sell" — which is a judgement about a judgement, made by
|
||||||
|
// somebody holding the packet. It says nothing about the grams, so
|
||||||
|
// `Nutrition` above is set either way.
|
||||||
|
if products[i].Showhealthscore {
|
||||||
|
products[i].Healthscore = found.Health
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetShowHealthScore turns one product's health score on or off for one shop.
|
||||||
|
//
|
||||||
|
// The merchant's decision, not Nearle's. The score comes from a third party
|
||||||
|
// matching a reference product by name — frequently under 60% confidence — and
|
||||||
|
// a shopkeeper holding the packet is better placed than that matcher to say
|
||||||
|
// whether the rating describes what they sell.
|
||||||
|
//
|
||||||
|
// Only the score moves. The nutrition figures are what the label states and are
|
||||||
|
// sent either way; turning this off disputes the judgement, not the grams.
|
||||||
|
func (s *productService) SetShowHealthScore(tenantID, productID int, show bool) error {
|
||||||
|
if tenantID <= 0 || productID <= 0 {
|
||||||
|
return fmt.Errorf("tenantid and productid are both required")
|
||||||
|
}
|
||||||
|
return s.repo.SetShowHealthScore(tenantID, productID, show)
|
||||||
|
}
|
||||||
|
|||||||
201
services/showHealthScore_test.go
Normal file
201
services/showHealthScore_test.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user