237 lines
8.1 KiB
Go
237 lines
8.1 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
|
|
|
|
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")
|
|
}
|
|
}
|