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 ensuredRefs []models.ProductLocationRef syncedRefs []models.ProductLocationRef 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 } 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 } 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) } }