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) } }