Files
backend_fiesta/services/productVisibility_test.go

609 lines
22 KiB
Go

package services
import (
"testing"
"nearle/models"
"nearle/repositories"
)
/*
The rule these tests defend, read off the customer app's own query
(`repositories/productRepository.go` GetProducts and
`controllers/productController.go` GetProductsBySubcategory):
SELECT ... FROM products a
LEFT JOIN productlocations pl ON pl.productid = a.productid AND pl.tenantid = a.tenantid
WHERE a.categoryid = ? -- and the controller rejects 0 outright
AND pl.locationid = ? -- so the LEFT JOIN behaves as an inner one
A product is therefore visible to a shopper only when BOTH hold: it has a
non-zero category, and it has a productlocations row at that outlet. Nothing
else gates it — not `approved`, not `publishedat`, not `productstatus`, not the
stock level.
Both conditions were reachable states that no API could produce or repair, and
a sweep of all 262 tenants found 7 products and 6 outlets already in them —
outlets showing an empty shop while the console listed their stock.
*/
// fakeProductRepo records calls in order. The interface is large, so it is
// embedded rather than implemented: anything these tests do not exercise is nil
// and panics loudly if the code under test starts depending on it, which is the
// failure we want rather than a silent zero value.
type fakeProductRepo struct {
repositories.ProductRepository
calls []string
existing *models.Products
// Set only by tests that exercise the stable-key path; nil leaves the
// import falling through to the catalogueid lookup as before.
existingByImage *models.Products
linked [][2]any
ensuredRefs []models.ProductLocationRef
syncedRefs []models.ProductLocationRef
publishedRefs []models.ProductLocationRef
created []models.Products
categorySet map[int][2]int // productid -> {categoryid, subcategoryid}
}
func newFakeRepo() *fakeProductRepo {
return &fakeProductRepo{categorySet: map[int][2]int{}}
}
func (f *fakeProductRepo) CreateProductStock(stocks []models.Productstock) error {
f.calls = append(f.calls, "CreateProductStock")
return nil
}
func (f *fakeProductRepo) EnsureProductLocation(refs []models.ProductLocationRef) error {
f.calls = append(f.calls, "EnsureProductLocation")
f.ensuredRefs = append(f.ensuredRefs, refs...)
return nil
}
func (f *fakeProductRepo) SyncProductLocationStatus(refs []models.ProductLocationRef) error {
f.calls = append(f.calls, "SyncProductLocationStatus")
f.syncedRefs = append(f.syncedRefs, refs...)
return nil
}
// The stable-key lookup, which the import now tries FIRST.
//
// Answers nothing unless a test sets `existingByImage`, so the fallback to the
// catalogueid ref still runs and the existing cases keep exercising it. That
// order is the fix for renumbering: `catalogueid` changes on every re-scrape,
// so a miss there makes the import believe it has never seen the product and
// create a duplicate.
func (f *fakeProductRepo) FindTenantProductByImageID(tenantid int, imageid string) (*models.Products, error) {
f.calls = append(f.calls, "FindTenantProductByImageID")
return f.existingByImage, nil
}
func (f *fakeProductRepo) SetCatalogueLink(productid int, imageid string, catalogueid int) error {
f.calls = append(f.calls, "SetCatalogueLink")
f.linked = append(f.linked, [2]any{productid, imageid})
return nil
}
func (f *fakeProductRepo) FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error) {
f.calls = append(f.calls, "FindTenantProductByCatalogueRef")
return f.existing, nil
}
func (f *fakeProductRepo) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error {
f.calls = append(f.calls, "UpdateProductPricing")
return nil
}
func (f *fakeProductRepo) UpdateProductCategory(productid, categoryid, subcategoryid int) error {
f.calls = append(f.calls, "UpdateProductCategory")
// Mirrors the real implementation's guard so the tests exercise the same
// rule the database would.
if categoryid <= 0 {
return nil
}
f.categorySet[productid] = [2]int{categoryid, subcategoryid}
return nil
}
// Publishing what an import brought in priced.
//
// The gate exists to stop an UNPRICED product reaching a till; a spreadsheet
// import carries the price, so demanding a second manual release protected
// nothing — and cost the merchant sight of their own stock, because nothing in
// the backend filters on `publishedat` but the store catalogue screen does.
// A fresh import creates the snapshot; the existing cases all take the
// re-import branch, so this only became reachable when publishing did.
func (f *fakeProductRepo) CreateProductReturningID(product models.Products) (int, error) {
f.calls = append(f.calls, "CreateProductReturningID")
f.created = append(f.created, product)
return 9001, nil
}
func (f *fakeProductRepo) PublishPricedLocations(refs []models.ProductLocationRef) error {
f.calls = append(f.calls, "PublishPricedLocations")
f.publishedRefs = append(f.publishedRefs, refs...)
return nil
}
func (f *fakeProductRepo) CreateProductLocation(input []models.Productlocations) error {
f.calls = append(f.calls, "CreateProductLocation")
return nil
}
// fakeCatalogueService returns one fixed catalogue product.
type fakeCatalogueService struct {
CatalogueService
product *models.CatalogueProduct
}
func (f *fakeCatalogueService) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
return f.product, nil
}
func indexOf(calls []string, want string) int {
for i, c := range calls {
if c == want {
return i
}
}
return -1
}
// The end-to-end requirement, at the point it was broken: request a product,
// approve it, receive the stock — and the shopper can buy it.
//
// Receiving used to write the stock ledger and then call
// SyncProductLocationStatus, which is an UPDATE. At an outlet with no
// productlocations row it updated nothing and returned success, so the console
// showed the stock climbing and the app never listed the product.
func TestReceivingStockPutsTheProductOnTheOutletsShelf(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135,
Locationid: 1170,
Productid: 7086,
Quantity: 25,
Stocktype: "in",
}})
if err != nil {
t.Fatalf("CreateProductStock: %v", err)
}
if len(repo.ensuredRefs) != 1 {
t.Fatalf("the product was never shelved: EnsureProductLocation saw %d refs, want 1", len(repo.ensuredRefs))
}
got := repo.ensuredRefs[0]
if got.Tenantid != 1135 || got.Locationid != 1170 || got.Productid != 7086 {
t.Errorf("shelved the wrong row: %+v", got)
}
}
// Order matters and is not incidental.
//
// EnsureProductLocation seeds the new row "outofstock" because that is true at
// the instant of the insert. SyncProductLocationStatus then derives the real
// value from the ledger. Run the other way round, a genuine receipt would leave
// a freshly created row reading "outofstock" until the NEXT delivery.
func TestTheShelfIsCreatedBeforeAvailabilityIsDerived(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
if err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 25, Stocktype: "in",
}}); err != nil {
t.Fatalf("CreateProductStock: %v", err)
}
ensure := indexOf(repo.calls, "EnsureProductLocation")
sync := indexOf(repo.calls, "SyncProductLocationStatus")
if ensure < 0 || sync < 0 {
t.Fatalf("expected both calls, got %v", repo.calls)
}
if ensure > sync {
t.Errorf("EnsureProductLocation must run before SyncProductLocationStatus, got %v", repo.calls)
}
}
// An "out" movement must shelve the product too.
//
// The location row is what makes the product addressable at all, so a sale
// recorded at an outlet that somehow has no row should create it and then be
// correctly derived as outofstock — not be silently dropped.
func TestAnOutwardMovementAlsoShelves(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
if err := svc.CreateProductStock([]models.Productstock{{
Tenantid: 1135, Locationid: 1170, Productid: 7086, Quantity: 3, Stocktype: "out",
}}); err != nil {
t.Fatalf("CreateProductStock: %v", err)
}
if len(repo.ensuredRefs) != 1 {
t.Fatalf("an out movement did not shelve: %v", repo.calls)
}
}
// Re-importing is the repair path for a product stuck at categoryid 0, and
// until now it silently was not: the existing-product branch corrected pricing
// and nothing else, so the obvious fix appeared to work and changed nothing.
func TestReimportingCorrectsAnUncategorisedProduct(t *testing.T) {
repo := newFakeRepo()
repo.existing = &models.Products{Productid: 7086, Tenantid: 1135, Categoryid: 0}
svc := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 42, Brand: "cadbury", ProductName: "Cadbury Dairy Milk 100g"},
})
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1135, Locationid: 1170, Brand: "cadbury", Catalogueid: 42,
Categoryid: 2, Retailprice: 50,
}})
if err != nil {
t.Fatalf("ImportCatalogueProduct: %v", err)
}
got, ok := repo.categorySet[7086]
if !ok {
t.Fatalf("re-import did not touch the category; calls were %v", repo.calls)
}
if got[0] != 2 {
t.Errorf("categoryid = %d, want 2", got[0])
}
}
// The correction must never run backwards. An import that omits the category
// has nothing to say about it, and writing 0 would create exactly the invisible
// state this whole change exists to remove.
func TestReimportWithoutACategoryLeavesTheExistingOneAlone(t *testing.T) {
repo := newFakeRepo()
repo.existing = &models.Products{Productid: 7043, Tenantid: 1135, Categoryid: 2}
svc := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 9, Brand: "generic", ProductName: "Apple"},
})
err := svc.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1135, Locationid: 1170, Brand: "generic", Catalogueid: 9,
Categoryid: 0, Retailprice: 120,
}})
if err != nil {
t.Fatalf("ImportCatalogueProduct: %v", err)
}
if _, overwritten := repo.categorySet[7043]; overwritten {
t.Errorf("a category-less import overwrote an existing category")
}
}
/*
"If we have the stock, we can order it" — the customer app's browse rule.
The products below are Suriya Store's Peelamedu shelf as it actually stood:
three products listed, one with stock. Before this filter the app offered all
three, so a shopper could order two chocolate bars the shop did not have.
*/
func names(products []models.Products) []string {
out := make([]string, 0, len(products))
for _, p := range products {
out = append(out, p.Productname)
}
return out
}
var peelamedu = []models.Products{
{Productid: 7084, Productname: "Cadbury 5 Star 9.8 g", Productstock: 0},
{Productid: 7085, Productname: "Cadbury 5 Star 18g", Productstock: 0},
{Productid: 7086, Productname: "Cadbury Dairy Milk 100g", Productstock: 25},
}
func TestOnlyProductsWithStockAreOffered(t *testing.T) {
got := InStockOnly(peelamedu, 1170)
if len(got) != 1 {
t.Fatalf("offered %d products, want 1: %v", len(got), names(got))
}
if got[0].Productid != 7086 {
t.Errorf("offered %d, want 7086 (the only one in stock)", got[0].Productid)
}
}
// An outlet with nothing in stock offers nothing — it does not fall back to
// showing the shelf. An empty shop is the honest answer.
func TestAnOutletWithNoStockOffersNothing(t *testing.T) {
empty := []models.Products{
{Productid: 7084, Productstock: 0},
{Productid: 7085, Productstock: 0},
}
if got := InStockOnly(empty, 1172); len(got) != 0 {
t.Errorf("offered %d products from an empty shelf", len(got))
}
}
// Without an outlet, GetProducts scopes its balance subquery to locationid 0,
// which matches nothing and returns every product with Productstock 0. Filtering
// there would empty the catalogue instead of leaving it unscoped.
func TestAnUnscopedBrowseIsNotFiltered(t *testing.T) {
if got := InStockOnly(peelamedu, 0); len(got) != 3 {
t.Errorf("unscoped browse returned %d, want all 3 unfiltered", len(got))
}
}
// A negative balance is not stock. The ledger can go below zero when an "out"
// exceeds what was received, and `> 0` must not read that as sellable.
func TestANegativeBalanceIsNotStock(t *testing.T) {
oversold := []models.Products{{Productid: 7084, Productstock: -3}}
if got := InStockOnly(oversold, 1170); len(got) != 0 {
t.Errorf("an oversold product was offered for sale")
}
}
func TestFilteringDoesNotDisturbTheCallersSlice(t *testing.T) {
source := []models.Products{
{Productid: 1, Productstock: 0},
{Productid: 2, Productstock: 5},
}
_ = InStockOnly(source, 1170)
if len(source) != 2 || source[0].Productid != 1 || source[1].Productid != 2 {
t.Errorf("the input slice was modified: %+v", source)
}
}
func (f *fakeProductRepo) GetSubcategories(categoryID int) ([]models.Subcategory, error) {
return nil, nil
}
// Returns (nil, nil) so GetProductsBySubcategory falls through to its plain
// {"details": ...} shape. The tenant header block is not what this test is about.
func (f *fakeProductRepo) GetTenantInfo(tenantID, applocationID int) (map[string]interface{}, error) {
return nil, nil
}
func (f *fakeProductRepo) GetProducts(params models.ProductFilter) ([]models.Products, error) {
f.calls = append(f.calls, "GetProducts")
return peelamedu, nil
}
// The filter has to be WIRED, not merely written.
//
// The tests above exercise InStockOnly directly, which would keep passing if
// nobody called it. This goes through the endpoint the customer app actually
// hits and asserts on what a shopper would be shown.
func TestTheBrowseEndpointOffersOnlyStockedProducts(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
CategoryID: 2, TenantID: 1135, LocationID: 1170,
})
if err != nil {
t.Fatalf("GetProductsBySubcategory: %v", err)
}
groups, _ := result["details"].([]models.SubcategoryProductResponse)
var offered []models.Products
for _, g := range groups {
offered = append(offered, g.Products...)
}
if len(offered) != 1 {
t.Fatalf("the app was offered %d products, want 1: %v", len(offered), names(offered))
}
if offered[0].Productid != 7086 {
t.Errorf("offered product %d, want 7086 — the only one Peelamedu has", offered[0].Productid)
}
}
/*
The ordering screen's rule: a product with sizes offers the whole family, a
product without offers itself.
The repository decides that from the product's own `variants` column, so these
tests drive the service and assert on what the app receives.
*/
type variantRepo struct {
fakeProductRepo
gotVariantID int
gotProductID int
rows []models.Products
}
func (v *variantRepo) GetProductByVariant(tenantid, variantid, locationid, productid int) ([]models.Products, error) {
v.gotVariantID = variantid
v.gotProductID = productid
return v.rows, nil
}
// The case that did not work at all: an ungrouped product. Every product for
// Suriya Store is `variants = 0`, so the old signature had no way to name one
// and the order screen received an empty list.
func TestOrderingAnUngroupedProductAsksByProductId(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 7086, Productname: "Cadbury Dairy Milk 100g", Productstock: 25},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
got, err := svc.GetProductByVariant(1135, 0, 1170, 7086)
if err != nil {
t.Fatalf("GetProductByVariant: %v", err)
}
if repo.gotProductID != 7086 {
t.Errorf("productid %d never reached the repository", repo.gotProductID)
}
if len(got) != 1 || got[0].Productid != 7086 {
t.Fatalf("want just the tapped product, got %v", names(got))
}
}
// A product that does belong to a group still returns the whole group, so the
// shopper can pick a size.
func TestOrderingAGroupedProductOffersEveryVariant(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 6995, Productname: "Strawberries 250g", Productstock: 4},
{Productid: 6996, Productname: "Strawberries 500g", Productstock: 9},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
got, err := svc.GetProductByVariant(1087, 44, 1097, 0)
if err != nil {
t.Fatalf("GetProductByVariant: %v", err)
}
if repo.gotVariantID != 44 {
t.Errorf("variantid %d never reached the repository", repo.gotVariantID)
}
if len(got) != 2 {
t.Fatalf("want both sizes offered, got %v", names(got))
}
}
// The product screen shows what was tapped, in stock or not.
//
// Filtered, an out-of-stock product answered with zero rows and the screen had
// nothing to draw. Nothing is sold by showing it: the browse list never offers
// it, and CreateOrder refuses the line independently.
func TestTheProductScreenStillShowsAnEmptyProduct(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 7085, Productname: "Cadbury 5 Star 18g", Productstock: 0},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
got, err := svc.GetProductByVariant(1135, 0, 1170, 7085)
if err != nil {
t.Fatalf("GetProductByVariant: %v", err)
}
if len(got) != 1 {
t.Fatalf("the tapped product vanished from its own screen: %v", names(got))
}
if got[0].Productstock != 0 {
t.Errorf("stock must survive so the app can grey the option out, got %d", got[0].Productstock)
}
}
// Every member of the family comes back, so the shopper sees the full range and
// the one they tapped is always among them. Tapping "Apple" used to return
// "Pineapple" — the only stocked member — which is not the product they asked
// for.
func TestTheWholeFamilyIsShownIncludingEmptyOnes(t *testing.T) {
repo := &variantRepo{rows: []models.Products{
{Productid: 7014, Productname: "Apple", Productstock: 0},
{Productid: 6994, Productname: "Pineapple", Productstock: 50},
{Productid: 6989, Productname: "Jammu Apple", Productstock: 0},
}}
svc := NewProductService(repo, &fakeCatalogueService{})
got, err := svc.GetProductByVariant(1087, 36, 1097, 7014)
if err != nil {
t.Fatalf("GetProductByVariant: %v", err)
}
if len(got) != 3 {
t.Fatalf("want all 3 family members, got %v", names(got))
}
tapped := false
for _, p := range got {
if p.Productid == 7014 {
tapped = true
}
}
if !tapped {
t.Error("the tapped product is missing from its own variant list")
}
}
// The browse list is the screen that must NOT offer an empty shelf, and it
// still does not. The two endpoints deliberately differ.
func TestBrowseStillHidesWhatTheShopDoesNotHave(t *testing.T) {
repo := newFakeRepo()
svc := NewProductService(repo, &fakeCatalogueService{})
result, err := svc.GetProductsBySubcategory(models.ProductFilter{
CategoryID: 2, TenantID: 1135, LocationID: 1170,
})
if err != nil {
t.Fatalf("GetProductsBySubcategory: %v", err)
}
groups, _ := result["details"].([]models.SubcategoryProductResponse)
var offered []models.Products
for _, g := range groups {
offered = append(offered, g.Products...)
}
if len(offered) != 1 || offered[0].Productid != 7086 {
t.Fatalf("browse should still offer only the stocked product, got %v", names(offered))
}
}
/*
A priced import releases itself.
The publish gate is there to stop an unpriced product reaching a till that would
ring it up at zero. A spreadsheet import carries the price on every row, so the
condition is already met at import time and a second manual release could only
ever answer "yes".
It was not a harmless extra click. Nothing in the backend filters on
`publishedat` — only the merchant's own store-catalogue screen does — so on
2026-08-31 seventeen products at R mart were on sale in the customer app, priced
and stocked, while the shopkeeper's store catalogue showed none of them. The one
person who could not see the stock was the one selling it.
*/
func TestImportPublishesWhatItPriced(t *testing.T) {
repo := newFakeRepo()
service := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{
ID: 1, Brand: "kohinoor", ProductName: "Kohinoor Charminar Rice 5kg",
ImageID: "kohinoor_kohinoor_charminar_rice_5kg",
},
})
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1147, Locationid: 1185, Brand: "kohinoor", Catalogueid: 1,
Categoryid: 2, Retailprice: 395, Quantity: 22, Stocktype: "in",
}}); err != nil {
t.Fatalf("ImportCatalogueProduct: %v", err)
}
if len(repo.publishedRefs) != 1 {
t.Fatalf("want the imported row released, got %d refs (calls: %v)",
len(repo.publishedRefs), repo.calls)
}
got := repo.publishedRefs[0]
// The branch that was imported into, and only that one. Publishing
// tenant-wide would put products in outlets nobody chose.
if got.Tenantid != 1147 || got.Locationid != 1185 {
t.Errorf("released the wrong row: %+v", got)
}
}
// Order matters: publishing addresses (tenantid, locationid, productid), and a
// brand-new snapshot has no productid until its rows are written.
func TestImportPublishesAfterTheRowsExist(t *testing.T) {
repo := newFakeRepo()
service := NewProductService(repo, &fakeCatalogueService{
product: &models.CatalogueProduct{ID: 1, Brand: "colin", ProductName: "Colin Glass Cleaner 500ml"},
})
if err := service.ImportCatalogueProduct([]models.ImportCatalogueProductRequest{{
Tenantid: 1147, Locationid: 1185, Brand: "colin", Catalogueid: 1,
Categoryid: 2, Retailprice: 105, Quantity: 42,
}}); err != nil {
t.Fatalf("ImportCatalogueProduct: %v", err)
}
create, publish := -1, -1
for i, call := range repo.calls {
if call == "CreateProductLocation" {
create = i
}
if call == "PublishPricedLocations" {
publish = i
}
}
if create < 0 || publish < 0 || publish < create {
t.Fatalf("want CreateProductLocation before PublishPricedLocations, got %v", repo.calls)
}
}