276 lines
9.6 KiB
Go
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")
|
|
}
|
|
}
|