Files
backend_fiesta/services/tools/shopfloor_test.go
2026-09-23 17:26:13 +05:30

276 lines
9.6 KiB
Go

package tools
import (
"context"
"errors"
"strings"
"testing"
"time"
"nearle/models"
)
// The caller these tools are exercised as. Named rather than reusing `anyone`
// so a change to one file's fixture cannot quietly alter another's meaning.
var merchantCaller = Caller{Userid: 904, Tenantid: 1147}
func call1(t *testing.T, tool Tool, args map[string]any, caller Caller) (Result, error) {
t.Helper()
r := New(nil)
if err := r.Register(tool); err != nil {
t.Fatalf("registering: %v", err)
}
return r.Call(context.Background(), Agent{Name: "orders", Tools: []string{tool.Name}}, tool.Name, args, caller)
}
/* ── Delivery progress ─────────────────────────────────────────────────── */
func TestDeliveryProgressCountsTheLadderInOrder(t *testing.T) {
rows := []models.Deliveryinfo{
{Orderstatus: "delivered"}, {Orderstatus: "delivered"},
{Orderstatus: "active"},
{Orderstatus: "pending"}, {Orderstatus: "pending"}, {Orderstatus: "pending"},
}
result, err := call1(t, DeliveryProgress(&fakeDeliveries{rows: rows}), nil, merchantCaller)
if err != nil {
t.Fatalf("calling: %v", err)
}
stages, ok := result.Rows.([]StageCount)
if !ok {
t.Fatalf("rows are not stages: %T", result.Rows)
}
// Journey order, not map order: "picked 3, accepted 8" makes a reader
// rebuild the ladder in their head every time.
if stages[0].Stage != "Not yet accepted" {
t.Fatalf("the ladder is out of order: %+v", stages)
}
if result.Count != 4 {
t.Fatalf("in-progress count is %d, not 4", result.Count)
}
}
func TestDeliveryProgressLeavesOutEmptyStages(t *testing.T) {
// A ladder of zeroes buries the rungs that have anything on them.
result, _ := call1(t, DeliveryProgress(&fakeDeliveries{
rows: []models.Deliveryinfo{{Orderstatus: "pending"}},
}), nil, merchantCaller)
stages := result.Rows.([]StageCount)
if len(stages) != 1 {
t.Fatalf("empty stages were listed: %+v", stages)
}
}
func TestSkippedAndRejectedAreStoppedNotInProgress(t *testing.T) {
// Both need a person, and neither is somebody currently carrying the job.
result, _ := call1(t, DeliveryProgress(&fakeDeliveries{
rows: []models.Deliveryinfo{{Orderstatus: "skipped"}, {Orderstatus: "rejected"}},
}), nil, merchantCaller)
if result.Count != 0 {
t.Fatalf("stopped jobs counted as in progress: %d", result.Count)
}
for _, stage := range result.Rows.([]StageCount) {
if stage.Live {
t.Fatalf("%q reported as in progress", stage.Stage)
}
}
}
func TestAnUnknownStatusIsReportedNotSwallowed(t *testing.T) {
// A stage nobody counted is how a whole category of work goes missing from
// a board that looks complete.
result, _ := call1(t, DeliveryProgress(&fakeDeliveries{
rows: []models.Deliveryinfo{{Orderstatus: "teleported"}},
}), nil, merchantCaller)
if !strings.Contains(result.Note, "teleported") {
t.Fatalf("an unrecognised status vanished: %q", result.Note)
}
}
/* ── Pending approvals ─────────────────────────────────────────────────── */
type fakeApprovals struct {
rows []models.StockRequest
err error
seenStatus string
seenTenant int
}
func (f *fakeApprovals) GetStockRequests(tenantID, locationID int, status, date string, pageNo, pageSize int) ([]models.StockRequest, error) {
f.seenTenant = tenantID
f.seenStatus = status
return f.rows, f.err
}
func TestApprovalsAskForPendingOnly(t *testing.T) {
approvals := &fakeApprovals{}
if _, err := call1(t, PendingApprovals(approvals, nil), nil, merchantCaller); err != nil {
t.Fatalf("calling: %v", err)
}
if approvals.seenStatus != "Pending" {
t.Fatalf("asked for status %q", approvals.seenStatus)
}
if approvals.seenTenant != 1147 {
t.Fatalf("asked for tenant %d", approvals.seenTenant)
}
}
func TestTheLongestWaitIsFirst(t *testing.T) {
now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
approvals := &fakeApprovals{rows: []models.StockRequest{
{Requestid: 1, Productname: "Rice", Created: now.AddDate(0, 0, -1)},
{Requestid: 2, Productname: "Oil", Created: now.AddDate(0, 0, -9)},
}}
result, _ := call1(t, PendingApprovals(approvals, func() time.Time { return now }), nil, merchantCaller)
rows := result.Rows.([]PendingApproval)
if rows[0].Requestid != 2 || rows[0].WaitingDays != 9 {
t.Fatalf("the nine-day-old request is not first: %+v", rows)
}
}
func TestARequestFromTheFutureDoesNotWaitNegativeDays(t *testing.T) {
now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
approvals := &fakeApprovals{rows: []models.StockRequest{
{Requestid: 1, Created: now.AddDate(0, 0, 3)},
}}
result, _ := call1(t, PendingApprovals(approvals, func() time.Time { return now }), nil, merchantCaller)
if got := result.Rows.([]PendingApproval)[0].WaitingDays; got != 0 {
t.Fatalf("a clock ahead of ours produced %d days", got)
}
}
/* ── Low stock ─────────────────────────────────────────────────────────── */
type fakeStocks struct {
rows []models.Productstocks
err error
seenTenant string
seenBranch string
}
func (f *fakeStocks) GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error) {
f.seenTenant, f.seenBranch = tenantID, locationID
return f.rows, f.err
}
func TestOutOfStockIsDistinctFromLow(t *testing.T) {
// "Out" and "nearly out" need different actions, and a model left to infer
// the difference from a number will sometimes call zero "low".
stocks := &fakeStocks{rows: []models.Productstocks{
{Productid: 1, Productname: "Rice", Quantity: 0},
{Productid: 2, Productname: "Oil", Quantity: 3},
{Productid: 3, Productname: "Salt", Quantity: 40},
}}
result, _ := call1(t, LowStock(stocks), nil, merchantCaller)
rows := result.Rows.([]LowStockLine)
if len(rows) != 2 {
t.Fatalf("a well-stocked product was listed: %+v", rows)
}
if rows[0].State != "out" || rows[1].State != "low" {
t.Fatalf("states are wrong: %+v", rows)
}
}
func TestTheLowStockThresholdCanBeMoved(t *testing.T) {
// Five is a guess — nothing in this backend records a reorder level — so
// the person asking has to be able to change it.
stocks := &fakeStocks{rows: []models.Productstocks{{Productid: 1, Quantity: 12}}}
quiet, _ := call1(t, LowStock(stocks), nil, merchantCaller)
if quiet.Count != 0 {
t.Fatal("twelve was low at the default threshold")
}
loud, _ := call1(t, LowStock(stocks), map[string]any{"at_or_below": 20}, merchantCaller)
if loud.Count != 1 {
t.Fatal("twelve was not low at a threshold of twenty")
}
}
func TestLowStockIsStableBetweenRefetches(t *testing.T) {
// Two products on the same count must not swap places every refresh.
stocks := &fakeStocks{rows: []models.Productstocks{
{Productid: 2, Productname: "Zinc", Quantity: 1},
{Productid: 1, Productname: "Atta", Quantity: 1},
}}
result, _ := call1(t, LowStock(stocks), nil, merchantCaller)
rows := result.Rows.([]LowStockLine)
if rows[0].Product != "Atta" {
t.Fatalf("ties are not broken by name: %+v", rows)
}
}
/* ── Tills ─────────────────────────────────────────────────────────────── */
type fakeTills struct {
rows []map[string]string
err error
}
func (f *fakeTills) LocationHealth(context.Context, string) ([]map[string]string, error) {
return f.rows, f.err
}
func TestAMissingHeartbeatIsNamedNotBlank(t *testing.T) {
// Presence expires from Redis, so absence IS the signal. A blank state
// reads as "fine" to a model.
tills := &fakeTills{rows: []map[string]string{{"terminalid": "T5EDD"}}}
result, _ := call1(t, TillsNotSyncing(tills), nil, Caller{Userid: 904, Tenantid: 1147, Locationid: 1185})
rows := result.Rows.([]TillStatus)
if rows[0].State != "no heartbeat" {
t.Fatalf("a till with no state reported as %q", rows[0].State)
}
if result.Count != 1 {
t.Fatalf("a silent till was not counted as a problem: %d", result.Count)
}
}
func TestTillKeysAreReadUnderEverySpellingTheHashUses(t *testing.T) {
// Presence rows come from a Redis hash rather than a struct, so the
// spellings are whatever the writer used. Reading one name and finding
// nothing would report every till as silent.
tills := &fakeTills{rows: []map[string]string{{"terminal_id": "T99", "status": "online"}}}
result, _ := call1(t, TillsNotSyncing(tills), nil, Caller{Userid: 904, Tenantid: 1147, Locationid: 1185})
rows := result.Rows.([]TillStatus)
if rows[0].Terminal != "T99" {
t.Fatalf("the terminal id was not found: %+v", rows[0])
}
if result.Count != 0 {
t.Fatal("an online till was counted as a problem")
}
}
func TestTillsRefuseAnAllBranchesQuestionRatherThanAnsweringEmpty(t *testing.T) {
// Presence is keyed by outlet and there is no all-branches read. An empty
// list would be reported as "all tills are fine".
_, err := call1(t, TillsNotSyncing(&fakeTills{}), nil, merchantCaller)
if err == nil {
t.Fatal("an all-branches till question was answered")
}
}
func TestNoTerminalsAtAllIsExplained(t *testing.T) {
result, _ := call1(t, TillsNotSyncing(&fakeTills{}), nil, Caller{Userid: 904, Tenantid: 1147, Locationid: 1185})
if result.Note == "" {
t.Fatal("a branch with no terminals says nothing")
}
}
func TestABrokenPresenceReadIsAnError(t *testing.T) {
_, err := call1(t, TillsNotSyncing(&fakeTills{err: errors.New("redis is down")}), nil,
Caller{Userid: 904, Tenantid: 1147, Locationid: 1185})
if err == nil {
t.Fatal("a failed presence read was reported as healthy tills")
}
}