changes on agent

This commit is contained in:
2026-08-31 12:33:10 +05:30
parent 76554d26e5
commit ffc66462fc
9 changed files with 715 additions and 25 deletions

View File

@@ -0,0 +1,132 @@
package controllers
import (
"net/http"
"strconv"
"strings"
"nearle/models"
"nearle/services"
"github.com/gofiber/fiber/v2"
)
type CatalogueUploadController struct {
service services.CatalogueUploadService
}
func NewCatalogueUploadController(service services.CatalogueUploadService) *CatalogueUploadController {
return &CatalogueUploadController{service: service}
}
// RecordUpload stores the receipt for a spreadsheet just accepted by the ingest
// service.
//
// Called the moment the drop is accepted, before any polling — that is the one
// instant the batch id is guaranteed to exist and guaranteed not to have been
// lost. Everything after it (the review wait, the run, the shelving) can be
// recovered from the id; the id itself cannot be recovered from anything.
func (ctl *CatalogueUploadController) RecordUpload(c *fiber.Ctx) error {
var input models.CatalogueUpload
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if strings.TrimSpace(input.Batchid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "batchid is required — it is the only credential for reading the result back",
"status": false,
})
}
if input.Tenantid <= 0 {
// A receipt with no tenant belongs to nobody and would be visible to
// every store login that asks for "all". Refused rather than stored
// unscoped.
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required — a receipt has to belong to a merchant",
"status": false,
})
}
if err := ctl.service.Record(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
return c.JSON(fiber.Map{"code": 200, "message": "Upload recorded", "status": true, "details": input})
}
// GetUploads lists receipts for whoever is asking.
//
// `tenantid=0` means every tenant and is how a Nearle Admin sees the platform.
// The console decides which to send from the session — the same rule the import
// itself follows — because a store login reading another merchant's receipts
// would see what that merchant stocks.
func (ctl *CatalogueUploadController) GetUploads(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid", "0"))
locationID, _ := strconv.Atoi(c.Query("locationid", "0"))
pageNo, _ := strconv.Atoi(c.Query("pageno", "1"))
pageSize, _ := strconv.Atoi(c.Query("pagesize", "50"))
data, err := ctl.service.List(tenantID, locationID, pageNo, pageSize)
if err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
// Never null. An empty list is a legitimate answer — a shop that has never
// uploaded — and a JSON null here makes every caller guard a case that is
// really just "none yet".
if data == nil {
data = []models.CatalogueUpload{}
}
return c.JSON(fiber.Map{"code": 200, "message": "Success", "status": true, "details": data})
}
// UpdateUpload caches what the ingest service last reported.
//
// Written by the browser that is polling, because that is where the reading
// happens: the batch id is the credential and the read is anonymous, so the
// person waiting is already holding the answer. This endpoint only writes it
// down so the next person does not have to wait for it again.
func (ctl *CatalogueUploadController) UpdateUpload(c *fiber.Ctx) error {
var input models.CatalogueUpload
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if strings.TrimSpace(input.Batchid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "batchid is required", "status": false,
})
}
if err := ctl.service.UpdateStatus(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
return c.JSON(fiber.Map{"code": 200, "message": "Upload updated", "status": true})
}
// MarkShelved records that the products reached a branch's shelf with a price
// and their opening stock.
//
// Separate from UpdateUpload on purpose. The ingest service confirms the global
// catalogue, which every merchant shares and which therefore carries no price
// and no stock — so "added" and "on sale here" are two different claims, made by
// two different systems, and a receipt that ran them together would report
// products as sellable that no customer can buy.
func (ctl *CatalogueUploadController) MarkShelved(c *fiber.Ctx) error {
var input struct {
Batchid string `json:"batchid"`
Shelved int `json:"shelved"`
Skipped int `json:"skipped"`
}
if err := c.BodyParser(&input); err != nil {
return c.JSON(fiber.Map{"code": http.StatusBadRequest, "message": "Invalid input", "status": false})
}
if strings.TrimSpace(input.Batchid) == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest, "message": "batchid is required", "status": false,
})
}
if err := ctl.service.MarkShelved(input.Batchid, input.Shelved, input.Skipped); err != nil {
return c.JSON(fiber.Map{"code": http.StatusInternalServerError, "message": err.Error(), "status": false})
}
return c.JSON(fiber.Map{"code": 200, "message": "Shelving recorded", "status": true})
}

View File

@@ -9,18 +9,19 @@ import (
)
type Facade struct {
UserController *controllers.UserController
ProductController *controllers.ProductController
OrderController *controllers.OrderController
DeliveriesController *controllers.DeliveriesController
UtilsController *controllers.UtilsController
TenantController *controllers.TenantController
PartnerController *controllers.PartnerController
CustomerController *controllers.CustomerController
StockRequestController *controllers.StockRequestController
CatalogueController *controllers.CatalogueController
PosController *controllers.PosController
LiveController *controllers.LiveController
UserController *controllers.UserController
ProductController *controllers.ProductController
OrderController *controllers.OrderController
DeliveriesController *controllers.DeliveriesController
UtilsController *controllers.UtilsController
TenantController *controllers.TenantController
PartnerController *controllers.PartnerController
CustomerController *controllers.CustomerController
StockRequestController *controllers.StockRequestController
CatalogueController *controllers.CatalogueController
PosController *controllers.PosController
LiveController *controllers.LiveController
CatalogueUploadController *controllers.CatalogueUploadController
// Held so the NATS consumer can reach the ingest without going through
// HTTP. Unexported: everything else should use the controller.
@@ -100,20 +101,29 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
// subscribers.
liveController := controllers.NewLiveController(posService)
// Catalogue Upload Module — our own receipt for every spreadsheet sent to
// the ingest service. Their host deletes an unreviewed drop after seven
// days and the batch id is the only credential for reading the result
// back, so the id has to be kept somewhere that outlives a browser tab.
catalogueUploadRepo := repositories.NewCatalogueUploadRepository(db)
catalogueUploadService := services.NewCatalogueUploadService(catalogueUploadRepo)
catalogueUploadController := controllers.NewCatalogueUploadController(catalogueUploadService)
return &Facade{
UserController: userController,
ProductController: productController,
OrderController: orderController,
DeliveriesController: deliveriesController,
UtilsController: utilsController,
TenantController: tenantController,
PartnerController: partnerController,
CustomerController: customerController,
StockRequestController: stockRequestController,
CatalogueController: catalogueController,
PosController: posController,
LiveController: liveController,
posService: posService,
UserController: userController,
ProductController: productController,
OrderController: orderController,
DeliveriesController: deliveriesController,
UtilsController: utilsController,
TenantController: tenantController,
PartnerController: partnerController,
CustomerController: customerController,
StockRequestController: stockRequestController,
CatalogueController: catalogueController,
PosController: posController,
LiveController: liveController,
CatalogueUploadController: catalogueUploadController,
posService: posService,
}
}

55
main.go
View File

@@ -102,6 +102,61 @@ func main() {
log.Fatal("could not backfill productlocations.publishedat:", err)
}
// Receipts for spreadsheets sent to the catalogue ingest service.
//
// The ingest service holds a drop on its own terms: an unreviewed one is
// deleted after seven days, the list of a sender's drops needs a credential
// production does not issue, and the batch id — which is the ONLY
// credential for reading a result back — is handed out once, to a browser.
// Lose it and the upload becomes unfindable even to the person who sent it.
// This table is where we keep it.
//
// Raw SQL rather than AutoMigrate, matching the two ALTERs above: GORM
// reconciling a model against a live table has rewritten more than was
// asked for here before, and `IF NOT EXISTS` makes this a no-op after the
// first boot.
if err := db.DB.Exec(`
CREATE TABLE IF NOT EXISTS catalogueuploads (
uploadid SERIAL PRIMARY KEY,
tenantid INT NOT NULL,
locationid INT NOT NULL DEFAULT 0,
categoryid INT NOT NULL DEFAULT 0,
batchid VARCHAR(64) NOT NULL,
runid VARCHAR(64),
filename TEXT,
sender TEXT,
uploadedby INT NOT NULL DEFAULT 0,
uploadedname TEXT,
rowcount INT NOT NULL DEFAULT 0,
laststatus VARCHAR(32) NOT NULL DEFAULT 'pending',
inserted INT NOT NULL DEFAULT 0,
backfilled INT NOT NULL DEFAULT 0,
skipped INT NOT NULL DEFAULT 0,
rejected INT NOT NULL DEFAULT 0,
shelvedcount INT NOT NULL DEFAULT 0,
skippedcount INT NOT NULL DEFAULT 0,
shelvedat TIMESTAMP,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`).Error; err != nil {
log.Fatal("could not create catalogueuploads:", err)
}
// The upload is recorded the instant the drop is accepted and then polled,
// so a refresh, a retried request or a second tab would each insert the same
// receipt again. The constraint is what makes `Record` idempotent — without
// it one upload shows up three times and none of them is wrong.
if err := db.DB.Exec(
`CREATE UNIQUE INDEX IF NOT EXISTS catalogueuploads_batchid_key
ON catalogueuploads (batchid)`).Error; err != nil {
log.Fatal("could not add catalogueuploads.batchid unique index:", err)
}
// Every read is "this shop's uploads, newest first".
if err := db.DB.Exec(
`CREATE INDEX IF NOT EXISTS catalogueuploads_scope_idx
ON catalogueuploads (tenantid, locationid, created DESC)`).Error; err != nil {
log.Println("⚠️ could not add catalogueuploads scope index:", err)
}
f := facade.NewFacade(db.DB, db.CatalogueDB)
routes.RegisterRoutes(app, f)

93
models/catalogueupload.go Normal file
View File

@@ -0,0 +1,93 @@
package models
import "time"
// CatalogueUpload is our own receipt for a spreadsheet sent to the ingest
// service — the record that the upload happened, who sent it, and for which
// shop.
//
// It exists because nothing else keeps one. The ingest service holds the drop,
// but on its terms and not ours:
//
// 1. **A drop nobody acts on is deleted after seven days**
// (`BATCH_RETENTION_DAYS`). If an admin never presses Start, the only proof
// the upload ever happened disappears — including from the sender.
// 2. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
// the credential that sent them, production has no API keys configured, and
// the one admin account is a superuser over their whole application. So the
// list is not available to us and should not be.
// 3. **The batch id is the credential.** `GET /api/uploads/catalog/{batch_id}`
// is anonymous by design — holding the id is the proof of having sent the
// drop. Which means whoever holds the id can read the result, and nobody
// else can. Losing the id loses the result permanently.
//
// So the id is the thing worth keeping, and this row is where we keep it. Every
// status field beside it is a CACHE of what the ingest service last told us,
// written by whichever browser was polling. It is never the authority — the
// service is — and it exists so a receipt reads sensibly before anyone re-polls
// it.
//
// The tenant and branch are ours alone. The ingest service writes the GLOBAL
// catalogue and has no concept of either, so "which shop was this for" is a
// question only this row can answer.
type CatalogueUpload struct {
Uploadid int `json:"uploadid" gorm:"primaryKey;autoIncrement;column:uploadid"`
// Who the sheet was for. The ingest service knows neither.
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
Locationid int `json:"locationid" gorm:"column:locationid"`
// The category every row was filed under, chosen at upload time. Worth
// keeping: a product filed outside the category the app browses is
// invisible to shoppers, and this is the only record of what was chosen.
Categoryid int `json:"categoryid" gorm:"column:categoryid"`
// The drop id, and the only thing here that cannot be reconstructed.
Batchid string `json:"batchid" gorm:"column:batchid"`
// The run an admin released the drop into, once they have. Cached from
// `released_to` so a receipt can be followed without re-walking the drop.
Runid string `json:"runid" gorm:"column:runid"`
Filename string `json:"filename" gorm:"column:filename"`
// The label the ingest inbox shows their admin. Stored so we can tell,
// afterwards, what they were looking at when they approved it.
Sender string `json:"sender" gorm:"column:sender"`
Uploadedby int `json:"uploadedby" gorm:"column:uploadedby"`
Uploadedname string `json:"uploadedname" gorm:"column:uploadedname"`
// Rows we parsed in the browser, before sending. Independent of anything
// the service reports, so a drop that never runs still says how big it was.
Rowcount int `json:"rowcount" gorm:"column:rowcount"`
// ── Cached from the ingest service, by whoever last polled ──────────────
Laststatus string `json:"laststatus" gorm:"column:laststatus;default:pending"`
// Their counts, so a settled receipt reads correctly with no network call.
Inserted int `json:"inserted" gorm:"column:inserted"`
Backfilled int `json:"backfilled" gorm:"column:backfilled"`
Skipped int `json:"skipped" gorm:"column:skipped"`
Rejected int `json:"rejected" gorm:"column:rejected"`
// ── Ours: the half the ingest service cannot do ────────────────────────
//
// Their pipeline writes the global catalogue, which every merchant shares
// and which therefore holds no price and no stock. Shelving is what turns
// "the product exists" into "this shop can sell it", and it is a separate
// action that can be left undone — so it is recorded separately.
Shelvedcount int `json:"shelvedcount" gorm:"column:shelvedcount"`
Skippedcount int `json:"skippedcount" gorm:"column:skippedcount"`
Shelvedat *time.Time `json:"shelvedat" gorm:"column:shelvedat"`
Created time.Time `json:"created" gorm:"column:created;autoCreateTime"`
Updated time.Time `json:"updated" gorm:"column:updated;autoUpdateTime"`
// Joined for display, never stored. A receipt outlives the page that made
// it, so it has to be able to name its own shop.
Tenantname string `json:"tenantname" gorm:"->;column:tenantname"`
Locationname string `json:"locationname" gorm:"->;column:locationname"`
}
func (CatalogueUpload) TableName() string {
return "catalogueuploads"
}

View File

@@ -0,0 +1,150 @@
package repositories
import (
"errors"
"strings"
"nearle/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type CatalogueUploadRepository interface {
Record(upload *models.CatalogueUpload) error
List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error)
UpdateStatus(upload *models.CatalogueUpload) error
MarkShelved(batchID string, shelved, skipped int) error
}
type catalogueUploadRepository struct {
db *gorm.DB
}
func NewCatalogueUploadRepository(db *gorm.DB) CatalogueUploadRepository {
return &catalogueUploadRepository{db: db}
}
// Record stores the receipt, or returns the one already stored.
//
// Idempotent on `batchid`, which matters more than it looks. The console writes
// this the instant the drop is accepted and then polls; a refresh, a retry after
// a dropped response, or two tabs watching the same upload would otherwise each
// insert a row, and the operator would see one upload three times.
//
// `DoNothing` rather than an update, because everything a second caller could
// supply here is the same data it supplied the first time. Progress arrives
// through UpdateStatus, which is the call that is allowed to overwrite.
func (r *catalogueUploadRepository) Record(upload *models.CatalogueUpload) error {
if strings.TrimSpace(upload.Batchid) == "" {
// Without the id the row is worthless: the id is the ONLY credential
// for reading the result back, and nothing else in the receipt can
// stand in for it.
return errors.New("batchid is required — it is the only way to read the result back")
}
if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "batchid"}},
DoNothing: true,
}).Create(upload).Error; err != nil {
return err
}
// A conflict leaves Uploadid zero and the caller with nothing to show, so
// the existing row is read back and returned as if it had just been made.
if upload.Uploadid == 0 {
return r.db.Where("batchid = ?", upload.Batchid).First(upload).Error
}
return nil
}
// List returns receipts newest first, scoped to whoever is asking.
//
// A tenant of 0 means "every tenant" and is how a Nearle Admin sees the whole
// platform; a store login always sends its own. The scoping is deliberately the
// same shape as the import itself — see the note on ImportScope in the console —
// because a receipt that could be read by the wrong shop would leak what another
// merchant stocks.
//
// The two names are LEFT joined. A tenant or branch can be deleted after the
// upload, and a receipt that vanishes with it would take the audit trail with
// it; joined this way the row survives, just unnamed.
func (r *catalogueUploadRepository) List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) {
var uploads []models.CatalogueUpload
query := r.db.Table("catalogueuploads").
Select("catalogueuploads.*, tenants.tenantname, tenantlocations.locationname").
Joins("left join tenants on tenants.tenantid = catalogueuploads.tenantid").
Joins("left join tenantlocations on tenantlocations.locationid = catalogueuploads.locationid")
if tenantID > 0 {
query = query.Where("catalogueuploads.tenantid = ?", tenantID)
}
if locationID > 0 {
query = query.Where("catalogueuploads.locationid = ?", locationID)
}
if pageNo < 1 {
pageNo = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
err := query.
Order("catalogueuploads.created DESC").
Offset((pageNo - 1) * pageSize).
Limit(pageSize).
Find(&uploads).Error
return uploads, err
}
// UpdateStatus caches what the ingest service last said about a drop.
//
// Only the fields the service owns are written, and they are written by column
// rather than by struct: a zero-valued struct field would otherwise be skipped
// by GORM's non-zero update, and "0 inserted" is a real answer that has to be
// storable. Everything the console owns — tenant, branch, filename, who sent it
// — is deliberately not touched, so a stale browser cannot rewrite the identity
// of a receipt while reporting progress on it.
func (r *catalogueUploadRepository) UpdateStatus(upload *models.CatalogueUpload) error {
if strings.TrimSpace(upload.Batchid) == "" {
return errors.New("batchid is required")
}
fields := map[string]any{
"laststatus": upload.Laststatus,
"inserted": upload.Inserted,
"backfilled": upload.Backfilled,
"skipped": upload.Skipped,
"rejected": upload.Rejected,
}
// Never blanked. A drop reports `released_to` once and then the drop itself
// is spent; a later poll of the RUN carries no run id of its own, and
// writing that empty value back would lose the only pointer from the id we
// hold to the results.
if strings.TrimSpace(upload.Runid) != "" {
fields["runid"] = upload.Runid
}
return r.db.Model(&models.CatalogueUpload{}).
Where("batchid = ?", upload.Batchid).
Updates(fields).Error
}
// MarkShelved records the half of the story the ingest service knows nothing
// about: that the products were priced, put on a branch's shelf and given their
// opening stock.
//
// Kept apart from UpdateStatus because the two answer different questions and
// fail independently. "The catalogue has them" and "this shop can sell them"
// are not the same claim, and a receipt that showed only the first would report
// success for products no customer can buy.
func (r *catalogueUploadRepository) MarkShelved(batchID string, shelved, skipped int) error {
if strings.TrimSpace(batchID) == "" {
return errors.New("batchid is required")
}
return r.db.Model(&models.CatalogueUpload{}).
Where("batchid = ?", batchID).
Updates(map[string]any{
"shelvedcount": shelved,
"skippedcount": skipped,
"shelvedat": gorm.Expr("CURRENT_TIMESTAMP"),
}).Error
}

View File

@@ -20,4 +20,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
RegisterCustomerRoutes(api, f)
RegisterCatalogueRoutes(api, f)
RegisterPosRoutes(api, f)
RegisterUploadRoutes(api, f)
}

33
routes/uploadroutes.go Normal file
View File

@@ -0,0 +1,33 @@
package routes
import (
"nearle/facade"
"github.com/gofiber/fiber/v2"
)
// Receipts for spreadsheets sent to the catalogue ingest service.
//
// Web only, and that is not an oversight. These are a back-office audit trail —
// who uploaded what, for which shop, and whether it reached the shelf. The
// customer app has no use for any of it, and mounting them under /mob would
// publish one merchant's stocking activity to an endpoint built for shoppers.
func RegisterUploadRoutes(api fiber.Router, f *facade.Facade) {
uploads := api.Group("/v1/web/uploads")
// Written the instant a drop is accepted. The batch id is the only
// credential for reading the result back, and the ingest service deletes an
// unreviewed drop after seven days — so this is the call that keeps an
// upload findable at all.
uploads.Post("/record", f.CatalogueUploadController.RecordUpload)
uploads.Get("/list", f.CatalogueUploadController.GetUploads)
// Progress, cached from whichever browser is polling the ingest service.
uploads.Put("/update", f.CatalogueUploadController.UpdateUpload)
// The other half: priced, shelved and stocked at a branch. Kept apart from
// /update because the ingest service confirms the shared global catalogue
// and knows nothing about a shop's shelf.
uploads.Put("/shelved", f.CatalogueUploadController.MarkShelved)
}

View File

@@ -0,0 +1,56 @@
package services
import (
"nearle/models"
"nearle/repositories"
)
// CatalogueUploadService keeps the record of what was sent to the ingest
// service, for which shop, and what became of it.
//
// Deliberately thin. There is no business rule here yet and inventing one would
// be the wrong move: the ingest service is the authority on a drop's progress,
// and Fiesta's job is to remember the id long enough for somebody to ask it.
//
// In particular this service does NOT call the ingest service itself. It could,
// and it should not: their host is a single CPU-bound container that spends
// minutes per batch on image search, and pointing a Go backend's retry loop at
// it would turn our uptime into theirs. The browser already polls anonymously —
// the batch id is the credential — so the reading happens where the person
// waiting for it is, and Fiesta stores what that reading found.
type CatalogueUploadService interface {
Record(upload *models.CatalogueUpload) error
List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error)
UpdateStatus(upload *models.CatalogueUpload) error
MarkShelved(batchID string, shelved, skipped int) error
}
type catalogueUploadService struct {
repo repositories.CatalogueUploadRepository
}
func NewCatalogueUploadService(repo repositories.CatalogueUploadRepository) CatalogueUploadService {
return &catalogueUploadService{repo: repo}
}
func (s *catalogueUploadService) Record(upload *models.CatalogueUpload) error {
if upload.Laststatus == "" {
// The service's own first answer for an accepted drop. Not "queued":
// nothing runs on arrival, and a receipt that said queued would claim
// the pipeline had it when it is in fact waiting for a person.
upload.Laststatus = "pending"
}
return s.repo.Record(upload)
}
func (s *catalogueUploadService) List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) {
return s.repo.List(tenantID, locationID, pageNo, pageSize)
}
func (s *catalogueUploadService) UpdateStatus(upload *models.CatalogueUpload) error {
return s.repo.UpdateStatus(upload)
}
func (s *catalogueUploadService) MarkShelved(batchID string, shelved, skipped int) error {
return s.repo.MarkShelved(batchID, shelved, skipped)
}

View File

@@ -0,0 +1,160 @@
package services
import (
"errors"
"testing"
"nearle/models"
"nearle/repositories"
)
/*
What these defend.
A receipt exists for one reason: the ingest service's batch id is the ONLY
credential for reading a result back, it is handed out exactly once, and an
unreviewed drop is deleted after seven days. So the failure that matters is not
a wrong count — it is a receipt that gets stored without the id, or a receipt
whose status is reported as something more finished than it is.
`pending` in particular is not a formality. Nothing runs on arrival at the
ingest service; a drop waits for an admin to press Start. A receipt that
defaulted to `queued` would claim the pipeline had the file when it is in fact
waiting for a person, and the counts beside it — all zero, because nothing has
run — would read as an import that found nothing.
*/
// fakeUploadRepo records what reached the repository. Embedded rather than
// implemented: a method these tests do not exercise is nil and panics loudly if
// the service starts calling it, which is the failure worth having.
type fakeUploadRepo struct {
repositories.CatalogueUploadRepository
recorded []models.CatalogueUpload
updated []models.CatalogueUpload
shelved []struct {
batchID string
shelved, skipped int
}
err error
}
func (f *fakeUploadRepo) Record(upload *models.CatalogueUpload) error {
if f.err != nil {
return f.err
}
f.recorded = append(f.recorded, *upload)
return nil
}
func (f *fakeUploadRepo) UpdateStatus(upload *models.CatalogueUpload) error {
if f.err != nil {
return f.err
}
f.updated = append(f.updated, *upload)
return nil
}
func (f *fakeUploadRepo) MarkShelved(batchID string, shelved, skipped int) error {
if f.err != nil {
return f.err
}
f.shelved = append(f.shelved, struct {
batchID string
shelved, skipped int
}{batchID, shelved, skipped})
return nil
}
func (f *fakeUploadRepo) List(tenantID, locationID, pageNo, pageSize int) ([]models.CatalogueUpload, error) {
return nil, f.err
}
// A drop that has only just been accepted has not run, and the receipt has to
// say so. `queued` would be wrong in the specific way that matters: it means
// "approved, waiting for the worker", and this file is waiting for a person.
func TestRecordDefaultsToPendingNotQueued(t *testing.T) {
repo := &fakeUploadRepo{}
svc := NewCatalogueUploadService(repo)
upload := &models.CatalogueUpload{Batchid: "abc123", Tenantid: 1135}
if err := svc.Record(upload); err != nil {
t.Fatalf("Record: %v", err)
}
if len(repo.recorded) != 1 {
t.Fatalf("want 1 recorded receipt, got %d", len(repo.recorded))
}
if got := repo.recorded[0].Laststatus; got != "pending" {
t.Errorf("a just-accepted drop should read pending, got %q", got)
}
}
// A caller who already knows the status keeps it. The default is a fallback for
// a fresh drop, not an override — a receipt being re-recorded from a poll that
// already saw `running` must not be walked backwards.
func TestRecordKeepsAnExplicitStatus(t *testing.T) {
repo := &fakeUploadRepo{}
svc := NewCatalogueUploadService(repo)
if err := svc.Record(&models.CatalogueUpload{
Batchid: "abc123", Tenantid: 1135, Laststatus: "running",
}); err != nil {
t.Fatalf("Record: %v", err)
}
if got := repo.recorded[0].Laststatus; got != "running" {
t.Errorf("want the caller's own status preserved, got %q", got)
}
}
// A repository failure is the caller's problem, not something to swallow. The
// receipt is the only durable copy of the batch id, so a write that did not
// happen must never look like one that did.
func TestRecordSurfacesRepositoryFailure(t *testing.T) {
boom := errors.New("unique violation")
svc := NewCatalogueUploadService(&fakeUploadRepo{err: boom})
err := svc.Record(&models.CatalogueUpload{Batchid: "abc123", Tenantid: 1135})
if !errors.Is(err, boom) {
t.Fatalf("want the repository error surfaced, got %v", err)
}
}
// Zero is a real answer. A run that inserted nothing because every product was
// already present is a complete success, and the counts have to be storable as
// zero rather than treated as "not supplied".
func TestUpdateStatusCarriesZeroCounts(t *testing.T) {
repo := &fakeUploadRepo{}
svc := NewCatalogueUploadService(repo)
if err := svc.UpdateStatus(&models.CatalogueUpload{
Batchid: "abc123", Laststatus: "done", Inserted: 0, Skipped: 12,
}); err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
if len(repo.updated) != 1 {
t.Fatalf("want 1 update, got %d", len(repo.updated))
}
if repo.updated[0].Inserted != 0 || repo.updated[0].Skipped != 12 {
t.Errorf("counts mangled: %+v", repo.updated[0])
}
}
// Shelving is a separate claim from ingestion and travels separately. The
// ingest service confirms the shared global catalogue, which holds no price and
// no stock; only this records that a shop can actually sell the products.
func TestMarkShelvedPassesBothCounts(t *testing.T) {
repo := &fakeUploadRepo{}
svc := NewCatalogueUploadService(repo)
if err := svc.MarkShelved("abc123", 18, 2); err != nil {
t.Fatalf("MarkShelved: %v", err)
}
if len(repo.shelved) != 1 {
t.Fatalf("want 1 shelving record, got %d", len(repo.shelved))
}
got := repo.shelved[0]
if got.batchID != "abc123" || got.shelved != 18 || got.skipped != 2 {
t.Errorf("want abc123/18/2, got %v", got)
}
}