718 lines
26 KiB
Go
718 lines
26 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)
|
|
}
|
|
}
|
|
|
|
/*
|
|
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")
|
|
}
|
|
}
|