Files
backend_fiesta/services/productVisibility_test.go
2026-09-16 17:09:04 +05:30

847 lines
30 KiB
Go

package services
import (
"errors"
"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}
// Set to make the insert fail, for the tests that check a failed create
// does not hand back a half-made product.
createErr error
}
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")
if f.createErr != nil {
return 0, f.createErr
}
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)
}
}
/*
The grouped shape, which is what `getallproducts` answers with.
Measured 2026-09-02 on R mart: the app was offering four products with a zero
balance — Cadbury Bournvita 500g, Amul 90g, Amul 150g and a Dabur chewing gum.
A shopper could pick any of them and only find out at checkout, where the stock
check answers 409.
*/
func groupOf(stocks ...int) []models.Tenantproducts {
products := make([]models.Products, 0, len(stocks))
for i, s := range stocks {
products = append(products, models.Products{Productid: 100 + i, Productstock: s})
}
return []models.Tenantproducts{{Products: products}}
}
func TestAnEmptyShelfIsNotOfferedToShoppers(t *testing.T) {
got := InStockOnlyGrouped(groupOf(5, 0, 3, 0))
if len(got) != 1 {
t.Fatalf("expected the tenant group to survive, got %d groups", len(got))
}
if len(got[0].Products) != 2 {
t.Fatalf("expected 2 sellable products, got %d", len(got[0].Products))
}
for _, p := range got[0].Products {
if p.Productstock <= 0 {
t.Errorf("product %d has no stock and was still offered", p.Productid)
}
}
}
func TestAShopWithNothingOnTheShelfStillExists(t *testing.T) {
// The group is kept with an empty list. Dropping it would tell the app the
// shop is not there, which is a different and wrong statement: it is open,
// it just has nothing to sell today.
got := InStockOnlyGrouped(groupOf(0, 0))
if len(got) != 1 {
t.Fatalf("the shop disappeared: got %d groups", len(got))
}
if len(got[0].Products) != 0 {
t.Errorf("expected nothing sellable, got %d", len(got[0].Products))
}
}
func TestANegativeBalanceIsNotStockWhenGrouped(t *testing.T) {
// A ledger can go negative through correction. Negative is not "some".
got := InStockOnlyGrouped(groupOf(-4))
if len(got[0].Products) != 0 {
t.Errorf("a negative balance was treated as stock")
}
}
func TestFilteringGroupsDoesNotDisturbTheCallersSlice(t *testing.T) {
original := groupOf(1, 0)
_ = InStockOnlyGrouped(original)
if len(original[0].Products) != 2 {
t.Errorf("the caller's slice was modified: %d products left", len(original[0].Products))
}
}
/*
One product, three sizes.
R mart stocks Aachi Baby Fryums as three product rows — 100g, 500g and 1kg —
each with its own price and its own shelf. In the app that read as three
near-identical listings a shopper had to tell apart by a suffix. Grouped, it is
one listing with a size picker.
*/
func TestASizeOfAnotherProductIsNotListedOnItsOwn(t *testing.T) {
groups := []models.Tenantproducts{{Products: []models.Products{
{Productid: 7112}, // 100g — the parent
{Productid: 7113}, // 1kg — a size of it
{Productid: 7114}, // 500g — a size of it
}}}
got := WithoutVariantChildren(groups, map[int]bool{7113: true, 7114: true})
if len(got[0].Products) != 1 || got[0].Products[0].Productid != 7112 {
t.Fatalf("expected only the parent, got %v", got[0].Products)
}
}
func TestAShopWithNoGroupsIsUntouched(t *testing.T) {
// The common case by far, and it must not pay for the feature.
groups := []models.Tenantproducts{{Products: []models.Products{{Productid: 1}, {Productid: 2}}}}
got := WithoutVariantChildren(groups, map[int]bool{})
if len(got[0].Products) != 2 {
t.Errorf("an ungrouped shop lost products: %v", got[0].Products)
}
}
func TestTheShopSurvivesEvenIfEveryProductIsASize(t *testing.T) {
// Should not happen — a group always has a parent — but a listing is the
// wrong place to discover bad data, and an empty list beats a crash.
groups := []models.Tenantproducts{{Products: []models.Products{{Productid: 5}}}}
got := WithoutVariantChildren(groups, map[int]bool{5: true})
if len(got) != 1 || len(got[0].Products) != 0 {
t.Errorf("expected the shop to survive with nothing listed, got %v", got)
}
}
func TestFilteringChildrenDoesNotDisturbTheCallersSlice(t *testing.T) {
original := []models.Tenantproducts{{Products: []models.Products{{Productid: 1}, {Productid: 2}}}}
_ = WithoutVariantChildren(original, map[int]bool{2: true})
if len(original[0].Products) != 2 {
t.Errorf("the caller's slice was modified")
}
}
/*
A product with no price is unfinished, not free.
Import writes whatever the catalogue carries, and 159 of the 1,572 catalogue
rows carry no price — loose produce has none by design, some scraped rows never
had one. So a product can reach a shelf at ₹0 and go on sale at nothing.
Measured 2026-09-03: Kmart's entire customer-facing catalogue was five products,
every one at ₹0, all with stock.
*/
func TestAnUnpricedProductIsNotOfferedToShoppers(t *testing.T) {
groups := []models.Tenantproducts{{Products: []models.Products{
{Productid: 1, Price: 45},
{Productid: 2, Price: 0},
{Productid: 3, Price: 12.5},
}}}
got := PricedOnly(groups)
if len(got[0].Products) != 2 {
t.Fatalf("expected the two priced products, got %v", got[0].Products)
}
for _, product := range got[0].Products {
if product.Price <= 0 {
t.Errorf("product %d has no price and was still offered", product.Productid)
}
}
}
func TestANegativePriceIsNotAPrice(t *testing.T) {
got := PricedOnly([]models.Tenantproducts{{Products: []models.Products{{Productid: 1, Price: -5}}}})
if len(got[0].Products) != 0 {
t.Error("a negative price was treated as sellable")
}
}
func TestAShopWithNothingPricedStillExists(t *testing.T) {
// The shop is open; it has nothing ready to sell. Dropping the group would
// say the shop is not there, which is a different and wrong statement.
got := PricedOnly([]models.Tenantproducts{{Products: []models.Products{{Productid: 1, Price: 0}}}})
if len(got) != 1 || len(got[0].Products) != 0 {
t.Errorf("expected the shop to survive with nothing on offer, got %v", got)
}
}
func TestPricingFilterDoesNotDisturbTheCallersSlice(t *testing.T) {
original := []models.Tenantproducts{{Products: []models.Products{{Productid: 1, Price: 10}, {Productid: 2, Price: 0}}}}
_ = PricedOnly(original)
if len(original[0].Products) != 2 {
t.Error("the caller's slice was modified")
}
}
/* ── Creating a product hands back its id ───────────────────────────────────
*
* `POST /products/create` answered `productid: 0` for every product it created:
* the repository took the struct by value, GORM wrote the generated id onto
* that copy, and the copy went out of scope. The endpoint is the only way to
* create a product, and a product cannot be priced or stocked without its id,
* so every caller had to re-read the tenant's whole catalogue and find its own
* row again by SKU — a column nothing enforces, in an importer that creates
* duplicates by design.
*/
func TestCreateProductReturnsTheIdTheDatabaseAssigned(t *testing.T) {
repo := &fakeProductRepo{}
svc := NewProductService(repo, &fakeCatalogueService{})
created, err := svc.CreateProduct(models.Products{
Tenantid: 9001,
Productname: "Test Rice 5kg",
Productsku: "TM-RICE-5K",
})
if err != nil {
t.Fatalf("CreateProduct: %v", err)
}
// 9001 is what the fake's CreateProductReturningID returns. The point is
// that it reaches the caller at all — it used to be dropped.
if created.Productid != 9001 {
t.Errorf("productid = %d, want 9001 — the id was lost on the way out", created.Productid)
}
// The rest of the product survives the round trip, because the response is
// what the console shows and what it prices and stocks against.
if created.Productsku != "TM-RICE-5K" || created.Productname != "Test Rice 5kg" {
t.Errorf("the product came back altered: %+v", created)
}
}
func TestCreateProductGoesThroughTheOneCreatePath(t *testing.T) {
// There were two repository methods doing this same INSERT, one of which
// 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.
repo := &fakeProductRepo{}
svc := NewProductService(repo, &fakeCatalogueService{})
if _, err := svc.CreateProduct(models.Products{Tenantid: 9001}); err != nil {
t.Fatalf("CreateProduct: %v", err)
}
if len(repo.calls) != 1 || repo.calls[0] != "CreateProductReturningID" {
t.Errorf("want exactly one call to CreateProductReturningID, got %v", repo.calls)
}
}
func TestAFailedCreateReturnsNoProduct(t *testing.T) {
// The caller prices and stocks against what comes back, so a half-made
// product with a zero id would be worse than an error — it would send a
// price and a stock movement to product 0.
repo := &fakeProductRepo{createErr: errors.New("duplicate key")}
svc := NewProductService(repo, &fakeCatalogueService{})
created, err := svc.CreateProduct(models.Products{Tenantid: 9001, Productsku: "DUP"})
if err == nil {
t.Fatal("a failed insert was reported as a success")
}
if created.Productid != 0 || created.Productsku != "" {
t.Errorf("a product was returned for a failed create: %+v", created)
}
}