Files
backend_fiesta/services/catalogueRelink_test.go
2026-08-31 15:51:36 +05:30

202 lines
7.1 KiB
Go

package services
import (
"errors"
"testing"
"nearle/models"
"nearle/repositories"
)
/*
The rules this pass must not break.
A catalogue link is the id a shop's product keeps back into the global
catalogue. That id is renumbered by every scrape — pepsico's live ids run
3, 6, 9 … 27, 30 — so eleven of the nineteen links on the platform pointed at
rows that no longer existed (measured 2026-08-31).
Repairing them by NAME was the obvious move and is wrong: the scrape that
renumbered them also changed their pack sizes (Cheetos Chips 100g became 250g,
Kurkure Menthol 10g became 50g). Tested against the live catalogue, zero of the
eleven had a name match — and a looser match would have attached a shop's 100g
product to a 250g one, which is a different SKU at a different price.
So a link can only be adopted or cleared, and the two failures that matter are
adopting the wrong row and clearing on a catalogue that is merely unreachable.
*/
type fakeRelinkRepo struct {
repositories.ProductRepository
products []models.Products
writes []struct {
productid int
imageid string
catalogueid int
}
}
func (f *fakeRelinkRepo) ListCatalogueLinkedProducts(tenantid int) ([]models.Products, error) {
return f.products, nil
}
func (f *fakeRelinkRepo) SetCatalogueLink(productid int, imageid string, catalogueid int) error {
f.writes = append(f.writes, struct {
productid int
imageid string
catalogueid int
}{productid, imageid, catalogueid})
return nil
}
type fakeCatalogue struct {
CatalogueService
rows map[int64]*models.CatalogueProduct
err error
}
func (f *fakeCatalogue) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
if f.err != nil {
return nil, f.err
}
return f.rows[id], nil
}
func newRelinkService(repo *fakeRelinkRepo, cat *fakeCatalogue) *productService {
return &productService{repo: repo, catalogueService: cat}
}
// The happy path: the id still resolves to the same product, so the stable key
// is adopted and the link survives the next scrape.
func TestRelinkAdoptsTheStableKey(t *testing.T) {
repo := &fakeRelinkRepo{products: []models.Products{
{Productid: 7075, Tenantid: 1147, Productbrand: "pepsico", Catalogueid: 27, Productname: "Cheetos Chips 250g"},
}}
cat := &fakeCatalogue{rows: map[int64]*models.CatalogueProduct{
27: {ID: 27, Brand: "pepsico", ProductName: "Cheetos Chips 250g", ImageID: "cheetos_chips_2d6bf74f"},
}}
report, err := newRelinkService(repo, cat).RelinkCatalogue(1147, false)
if err != nil {
t.Fatalf("RelinkCatalogue: %v", err)
}
if report.Linked != 1 || report.Cleared != 0 {
t.Fatalf("want 1 linked 0 cleared, got %+v", report)
}
if len(repo.writes) != 1 || repo.writes[0].imageid != "cheetos_chips_2d6bf74f" {
t.Errorf("stable key not written: %+v", repo.writes)
}
}
// The dangling case — eleven real products. The id resolves to nothing, so the
// link is cleared rather than guessed at.
func TestRelinkClearsADanglingLink(t *testing.T) {
repo := &fakeRelinkRepo{products: []models.Products{
{Productid: 7075, Tenantid: 1147, Productbrand: "pepsico", Catalogueid: 26, Productname: "Cheetos Chips 100g"},
}}
cat := &fakeCatalogue{rows: map[int64]*models.CatalogueProduct{}}
report, err := newRelinkService(repo, cat).RelinkCatalogue(1147, false)
if err != nil {
t.Fatalf("RelinkCatalogue: %v", err)
}
if report.Cleared != 1 {
t.Fatalf("want 1 cleared, got %+v", report)
}
if repo.writes[0].imageid != "" || repo.writes[0].catalogueid != 0 {
t.Errorf("a cleared link must keep nothing: %+v", repo.writes[0])
}
}
/*
The dangerous one. A renumber can hand an old id to an UNRELATED product, and
adopting it would silently point a shop's row at the wrong catalogue entry —
worse than no link at all, because it then looks repaired.
*/
func TestRelinkRefusesAnIdThatNowNamesSomethingElse(t *testing.T) {
repo := &fakeRelinkRepo{products: []models.Products{
{Productid: 7070, Tenantid: 1141, Productbrand: "pepsico", Catalogueid: 6, Productname: "Kurkure Menthol 10g"},
}}
cat := &fakeCatalogue{rows: map[int64]*models.CatalogueProduct{
// Id 6 exists, but it is the 50g pack now — a different SKU.
6: {ID: 6, Brand: "pepsico", ProductName: "Kurkure Menthol 50g", ImageID: "kurkure_menthol_49ef2d35"},
}}
report, err := newRelinkService(repo, cat).RelinkCatalogue(1141, false)
if err != nil {
t.Fatalf("RelinkCatalogue: %v", err)
}
if report.Linked != 0 || report.Cleared != 1 {
t.Fatalf("a different product must never be adopted: %+v", report)
}
if repo.writes[0].imageid != "" {
t.Errorf("wrong row adopted: %+v", repo.writes[0])
}
}
/*
An unreachable catalogue is not an empty one.
Without this the pass would read "every row is gone" the moment the catalogue
database was down, and clear every link on the platform in a single run — the
one outcome here that cannot be undone.
*/
func TestRelinkAbortsWhenTheCatalogueCannotBeRead(t *testing.T) {
repo := &fakeRelinkRepo{products: []models.Products{
{Productid: 7075, Tenantid: 1147, Productbrand: "pepsico", Catalogueid: 26, Productname: "Cheetos Chips 100g"},
}}
cat := &fakeCatalogue{err: errors.New("connection refused")}
if _, err := newRelinkService(repo, cat).RelinkCatalogue(1147, false); err == nil {
t.Fatal("want an error rather than a pass that clears every link")
}
if len(repo.writes) != 0 {
t.Errorf("nothing may be written when the catalogue cannot be read: %+v", repo.writes)
}
}
// A dry run reports exactly what a real one would do, and writes none of it.
// It is the default at the caller precisely so the plan can be read first.
func TestRelinkDryRunWritesNothing(t *testing.T) {
repo := &fakeRelinkRepo{products: []models.Products{
{Productid: 7075, Tenantid: 1147, Productbrand: "pepsico", Catalogueid: 26, Productname: "Cheetos Chips 100g"},
{Productid: 7081, Tenantid: 1147, Productbrand: "cadbury", Catalogueid: 9, Productname: "Cadbury Bournvita 500g"},
}}
cat := &fakeCatalogue{rows: map[int64]*models.CatalogueProduct{
9: {ID: 9, Brand: "cadbury", ProductName: "Cadbury Bournvita 500g", ImageID: "cadbury_bournvita_500g"},
}}
report, err := newRelinkService(repo, cat).RelinkCatalogue(1147, true)
if err != nil {
t.Fatalf("RelinkCatalogue: %v", err)
}
if report.Checked != 2 || report.Cleared != 1 || report.Linked != 1 {
t.Fatalf("the plan is wrong: %+v", report)
}
if len(repo.writes) != 0 {
t.Errorf("a dry run must write nothing, wrote %+v", repo.writes)
}
}
// Re-running the pass is a no-op on what it already fixed, so it is safe to
// leave in an operator's hands.
func TestRelinkIsIdempotent(t *testing.T) {
repo := &fakeRelinkRepo{products: []models.Products{
{Productid: 7081, Tenantid: 1147, Productbrand: "cadbury", Catalogueid: 9,
Productname: "Cadbury Bournvita 500g", Imageid: "cadbury_bournvita_500g"},
}}
cat := &fakeCatalogue{rows: map[int64]*models.CatalogueProduct{
9: {ID: 9, Brand: "cadbury", ProductName: "Cadbury Bournvita 500g", ImageID: "cadbury_bournvita_500g"},
}}
report, err := newRelinkService(repo, cat).RelinkCatalogue(1147, false)
if err != nil {
t.Fatalf("RelinkCatalogue: %v", err)
}
if report.Already != 1 || len(repo.writes) != 0 {
t.Errorf("want a no-op, got %+v / %+v", report, repo.writes)
}
}