catalogue images

This commit is contained in:
2026-08-29 11:29:15 +05:30
parent fb4fcaee66
commit c5109bf216
2 changed files with 150 additions and 1 deletions

View File

@@ -83,3 +83,74 @@ func TestCoreColumnsAreAlwaysSelectedPlainly(t *testing.T) {
t.Errorf("core columns should lead the select list, got:\n%s", got)
}
}
/*
Where product photos come from.
The owning team records what its image search found, per product, in
`image_url` and `image_urls`. Most of those are EXTERNAL — bigbasket, amazon, a
shop's own CDN — and only a subset were ever mirrored into our bucket. Reading
the bucket alone found photos for 2 of britannia's 6 products while they had
URLs for all 6.
*/
func TestJsonImageListIsRead(t *testing.T) {
// The shape their API returns today.
raw := `["https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg",` +
`"https://m.media-amazon.com/images/I/71n1Q3cQL3L.jpg"]`
got := parseImageList(raw)
if len(got) != 2 || got[0] != "https://www.bigbasket.com/media/uploads/p/l/270729_21-britannia.jpg" {
t.Fatalf("json list not parsed: %#v", got)
}
}
// The same column stored as a Postgres text[] rather than jsonb. Which one it
// is belongs to the owning team, and this side should not break when it moves.
func TestPostgresArrayImageListIsRead(t *testing.T) {
got := parseImageList(`{"https://a.example/1.jpg","https://b.example/2.jpg"}`)
if len(got) != 2 || got[1] != "https://b.example/2.jpg" {
t.Fatalf("pg array not parsed: %#v", got)
}
}
func TestEmptyImageListsAreNotPhotos(t *testing.T) {
for _, raw := range []string{"", " ", "{}", "[]", "null"} {
if got := parseImageList(raw); len(got) != 0 {
t.Errorf("%q should yield no photos, got %#v", raw, got)
}
}
}
// A malformed value must cost the photos, not the product.
func TestMalformedImageListDoesNotBreakTheRow(t *testing.T) {
if got := parseImageList(`["unterminated`); len(got) != 0 {
t.Errorf("expected no photos from malformed json, got %#v", got)
}
}
func TestTheListedUrlWinsAndDuplicatesCollapse(t *testing.T) {
row := catalogueProductRow{
ImageID: "britannia_britannia_marie_gold_250g",
ImageURL: "https://a.example/1.jpg",
ImageURLs: `["https://a.example/1.jpg","https://a.example/2.jpg"]`,
}
got := imagesFor("britannia", row)
if len(got) != 2 {
t.Fatalf("the primary url repeated in the list should appear once: %#v", got)
}
if got[0] != "https://a.example/1.jpg" || got[1] != "https://a.example/2.jpg" {
t.Errorf("order should follow image_urls: %#v", got)
}
}
// With no URLs recorded, the bucket listing is still consulted — it is the only
// source for anything ingested before these columns existed.
func TestNoRecordedUrlsFallsBackToTheBucket(t *testing.T) {
row := catalogueProductRow{ImageID: "cadbury_cadbury_5_star_5_gm"}
// db.GetImages returns nil when the image store was never initialised,
// which is the case here — the point is that it does not panic and does not
// invent a photo.
if got := imagesFor("cadbury", row); len(got) != 0 {
t.Errorf("expected no photos without an image store, got %#v", got)
}
}

View File

@@ -1,6 +1,7 @@
package repositories
import (
"encoding/json"
"errors"
"fmt"
"log"
@@ -69,6 +70,24 @@ var catalogueOptionalColumns = []struct{ Name, Present, Absent string }{
{"search_query", "search_query", "NULL::text AS search_query"},
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
// Where the photos actually are.
//
// The catalogue pipeline records what it found per product: `image_url` for
// the primary and `image_urls` for the rest. Most are EXTERNAL — bigbasket,
// amazon, a shop's own CDN — because stage 6 searches the web and only some
// results get mirrored into our bucket.
//
// This side ignored both columns and instead listed bucket objects under
// `daily/brands/{brand}/{image_id}/`, so a product showed a photo only if it
// happened to have been mirrored. Measured on britannia: 2 of 6 products had
// images here while the owning team had URLs for all 6.
//
// Cast to text and parsed by hand: the column is an array, and GORM's raw
// scan silently drops slice-kind destination fields — the same reason
// providers, highlights and nutrients are read this way.
{"image_url", "image_url", "NULL::text AS image_url"},
{"image_urls", "image_urls::text AS image_urls", "NULL::text AS image_urls"},
}
// columnsFor builds the SELECT list for one table from the columns it has.
@@ -118,6 +137,65 @@ type catalogueProductRow struct {
SearchQuery string
CreatedAt time.Time
UpdatedAt time.Time
ImageURL string
ImageURLs string
}
// imagesFor decides which photos a catalogue product carries.
//
// The pipeline's own `image_urls` first, then its single `image_url`, and the
// bucket listing last. That order is deliberate: the owning team records what
// it found for every product, and only a subset of those files were ever
// mirrored into our bucket — so listing the bucket alone finds photos for a
// minority of the catalogue and reports the rest as having none.
//
// The bucket stays as the fallback rather than being dropped. It is the only
// source for anything ingested before these columns existed, and it is the one
// source that cannot rot: a external URL is somebody else's CDN and will
// eventually 404, at which point the card falls through to the next photo.
func imagesFor(brand string, row catalogueProductRow) []string {
seen := make(map[string]bool)
var out []string
add := func(url string) {
url = strings.TrimSpace(url)
if url == "" || seen[url] {
return
}
seen[url] = true
out = append(out, url)
}
for _, url := range parseImageList(row.ImageURLs) {
add(url)
}
add(row.ImageURL)
if len(out) == 0 {
return db.GetImages(brand, row.ImageID)
}
return out
}
// parseImageList reads the list whichever way it was stored.
//
// A Postgres text[] casts to `{a,b}` and a jsonb column to `["a","b"]`, and the
// column type is the owning team's to change. Handling both here costs four
// lines and removes a whole class of "it worked until they migrated".
func parseImageList(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "{}" || raw == "[]" || raw == "null" {
return nil
}
if strings.HasPrefix(raw, "[") {
var urls []string
if err := json.Unmarshal([]byte(raw), &urls); err == nil {
return urls
}
// Falls through on a malformed value rather than dropping the row.
log.Printf("catalogue: could not parse image_urls as JSON: %.120s", raw)
return nil
}
return models.ParsePGArray(raw)
}
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
@@ -129,7 +207,7 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
Description: row.Description,
Category: row.Category,
ImageID: row.ImageID,
Images: db.GetImages(brand, row.ImageID),
Images: imagesFor(brand, row),
Size: row.Size,
VariantKey: row.VariantKey,
ProductSKU: row.ProductSKU,