202 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|