Add the assistant tool registry and its first tool

Phase 1 of Nearle Buddy: an agent names a tool, and the registry decides
whether that is allowed, whether the arguments make sense, who is asking,
and what gets recorded — then runs a handler a person wrote and tested.

No agent gets raw table access. The usual argument for tools over
generated SQL is safety; here there is a harder one. The fields on this
backend do not mean what their names say, and it is measured:
orders.deliverystatus is an empty string on all 181 rows of tenant 1147,
orders.orderstatus never carries the six middle delivery stages,
deliveries.ridername holds statuses as often as names, deliverytype is
empty on every row in production. A model writing SQL gets each of those
wrong with no error — it reports a cancel rate from a column of empty
strings and nobody can tell. A model calling a tool cannot, because the
correction lives in the handler beside the measurement that justified it.

Call does five things in order: find the tool, check the agent's
allow-list, validate arguments, confirm the caller is scoped to
something, run the handler — writing exactly one audit row whatever
happens, refusals included. A trail of successes answers "did anything
try to read another tenant?" with silence, which reads the same as no.

The model has no say in whose data is read. stuck_orders has no tenantid
field on its schema — absent, not rejected — and the tenant comes from
the session claims added in the previous commit. Arguments the tool did
not declare are dropped rather than passed on, so a model sending a
`where` clause gets it discarded.

stuck_orders: deliveries a rider was given and has not accepted, ten
minutes for a look, twenty-five for somebody now. Derived from assigntime
and orderstatus, so it does not depend on anyone having been watching.
Carries the wait in minutes, what to do, where to check it, and what it
covered. A capped answer says so — an empty result and a truncated one
look identical to a model and it will call both "none".

The audit sink writes to the log for now; a database sink is phase 8.
Nothing calls the registry yet: the loop and the model gateway are phase 2.

37 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:22:57 +05:30
parent c516c224e5
commit bb5f40926f
6 changed files with 1506 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import (
"nearle/controllers"
"nearle/repositories"
"nearle/services"
"nearle/services/tools"
"nearle/utils"
"gorm.io/gorm"
@@ -25,6 +26,13 @@ type Facade struct {
CatalogueUploadController *controllers.CatalogueUploadController
ScanController *controllers.ScanController
// Tools is what Nearle Buddy is allowed to do.
//
// Held on the facade because the assistant is not a module with a
// repository of its own — it is a door onto the services already built
// here, and every tool handler calls one of them rather than the database.
Tools *tools.Registry
// Held so the NATS consumer can reach the ingest without going through
// HTTP. Unexported: everything else should use the controller.
posService services.PosService
@@ -119,6 +127,22 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Faca
scanService := services.NewScanService(scanRepo, embedder)
scanController := controllers.NewScanController(scanService)
// The assistant registry. Built last, because every tool it holds is a thin
// wrapper over a service constructed above.
//
// A registration error panics rather than being logged. A duplicate name or
// a tool with no description is a programming mistake, and a server that
// starts with a tool silently absent answers real questions with "I cannot
// do that" for a reason nobody can see from the outside.
toolRegistry := tools.New(tools.LogAudit{})
for _, tool := range []tools.Tool{
tools.StuckOrders(deliveriesService, nil),
} {
if err := toolRegistry.Register(tool); err != nil {
panic("assistant tools: " + err.Error())
}
}
return &Facade{
UserController: userController,
ProductController: productController,
@@ -134,6 +158,7 @@ func NewFacade(db *gorm.DB, catalogueDB *gorm.DB, embedder utils.Embedder) *Faca
LiveController: liveController,
CatalogueUploadController: catalogueUploadController,
ScanController: scanController,
Tools: toolRegistry,
posService: posService,
}
}

162
services/tools/audit.go Normal file
View File

@@ -0,0 +1,162 @@
package tools
import (
"context"
"encoding/json"
"log"
"sort"
"strings"
"time"
)
// The audit trail.
//
// One row per call, including every refusal — the refusals are the interesting
// ones. A registry that recorded only successes would answer "did anything try
// to read another tenant?" with silence, which reads the same as "no".
//
// ── Why the sink is an interface ────────────────────────────────────────────
//
// Phase 1 writes to the log, because a table is a migration and this needs to
// work before that lands. Nothing else in the package knows that: the registry
// holds an `AuditSink`, so the database sink arrives later as a second
// implementation and no call site changes.
//
// ── Writes record intent, not outcome ───────────────────────────────────────
//
// Reads record what happened, which is all a read can be asked for. When write
// tools arrive they must record the ATTEMPT before the call leaves, not the
// result after it returns: a crash mid-write has to leave a trace that it was
// tried, and a row written only on success is a row that is missing exactly
// when it is needed.
const (
OutcomeOK = "ok"
OutcomeRefused = "refused"
OutcomeFailed = "failed"
)
// AuditEntry is one attempt to use a tool.
type AuditEntry struct {
At time.Time
Agent string
Tool string
Scope string
Userid int
Tenantid int
// The arguments as the handler received them — validated and defaulted, not
// as the model sent them. What actually ran is what is worth keeping.
Args map[string]any
// ok | refused | failed. `refused` is the guard saying no; `failed` is the
// handler breaking. Collapsing the two would hide a broken tool inside a
// count of things working as designed.
Outcome string
Detail string
Rows int
Took time.Duration
}
// AuditSink is where entries go.
//
// No error returned, deliberately. An audit sink that can fail a call gives a
// full disk the power to take the assistant down; one that cannot means a lost
// row, which is worse in theory and better in practice. A sink that cares
// should retry or buffer internally.
type AuditSink interface {
Write(ctx context.Context, entry AuditEntry)
}
// DiscardAudit keeps nothing. For tests that are not about the audit trail.
type DiscardAudit struct{}
func (DiscardAudit) Write(context.Context, AuditEntry) {}
// LogAudit writes one line per call to the standard logger.
//
// A line rather than JSON per field, because this is read by a person tailing
// logs during the rollout. The database sink can be structured.
type LogAudit struct{}
func (LogAudit) Write(_ context.Context, entry AuditEntry) {
log.Printf("assistant: %s", entry.Line())
}
// Line renders an entry for a log.
//
// Arguments are rendered sorted so two identical calls produce identical lines
// and a grep for one of them finds both. Go's map iteration is randomised, so
// without the sort the same call logs differently every time.
func (e AuditEntry) Line() string {
var b strings.Builder
b.WriteString(e.Outcome)
b.WriteString(" ")
b.WriteString(e.Agent)
b.WriteString("/")
b.WriteString(e.Tool)
if e.Tenantid > 0 {
b.WriteString(" tenant=")
b.WriteString(itoa(e.Tenantid))
} else {
// Explicitly, rather than by omission: "no tenant" on an assistant call
// is either staff or a bug, and both are worth being able to search for.
b.WriteString(" tenant=none")
}
b.WriteString(" user=")
b.WriteString(itoa(e.Userid))
if len(e.Args) > 0 {
keys := make([]string, 0, len(e.Args))
for key := range e.Args {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
value, err := json.Marshal(e.Args[key])
if err != nil {
value = []byte("?")
}
parts = append(parts, key+"="+string(value))
}
b.WriteString(" args{")
b.WriteString(strings.Join(parts, " "))
b.WriteString("}")
}
if e.Outcome == OutcomeOK {
b.WriteString(" rows=")
b.WriteString(itoa(e.Rows))
}
if e.Detail != "" {
b.WriteString(" detail=")
value, err := json.Marshal(e.Detail)
if err != nil {
b.WriteString("?")
} else {
b.Write(value)
}
}
b.WriteString(" took=")
b.WriteString(e.Took.Round(time.Millisecond).String())
return b.String()
}
func itoa(n int) string {
value, _ := json.Marshal(n)
return string(value)
}
// CollectAudit keeps entries in memory, for tests that ARE about the trail.
type CollectAudit struct{ Entries []AuditEntry }
func (c *CollectAudit) Write(_ context.Context, entry AuditEntry) {
c.Entries = append(c.Entries, entry)
}
func (c *CollectAudit) Last() (AuditEntry, bool) {
if len(c.Entries) == 0 {
return AuditEntry{}, false
}
return c.Entries[len(c.Entries)-1], true
}

431
services/tools/registry.go Normal file
View File

@@ -0,0 +1,431 @@
// Package tools is the registry every assistant call goes through.
//
// An agent does not reach the database. It names a tool, and this package
// decides whether that is allowed, whether the arguments make sense, who is
// asking, and what gets recorded — then runs a handler that was written by a
// person and tested.
//
// ── Why a registry rather than generated SQL ────────────────────────────────
//
// The usual reason is safety. Here there is a harder one: the fields on this
// backend do not mean what their names say, and it is measured and documented.
// `orders.deliverystatus` is an empty string on all 181 rows of tenant 1147.
// `orders.orderstatus` only ever carries pending, delivered or cancelled, so
// the six delivery stages in between never reach it. `deliveries.ridername`
// holds delivery statuses as often as names. `orders.deliverytype` is empty on
// every row in production, so filtering on it hides the entire list.
// `billedat` is local wall-clock labelled `Z`, which puts 19 of 20 bills in the
// future.
//
// A model writing SQL gets every one of those wrong, confidently, with no
// error — it reports a cancel rate from a column of empty strings and nobody
// can tell. A model calling a tool cannot, because the correction lives inside
// the handler with the measurement that justified it written beside it.
//
// So: no agent gets raw table access, and a tool that accepts a `where` string
// is a table with extra steps.
package tools
import (
"context"
"errors"
"fmt"
"sort"
"time"
)
// Scope separates reads from writes.
//
// Not decoration. A write tool goes through human approval before it runs
// (that is a later phase), and the registry is where the two are told apart, so
// the distinction has to be declared on the tool rather than inferred from its
// name.
type Scope string
const (
ScopeRead Scope = "read"
ScopeWrite Scope = "write"
)
// Kind is the type of one argument. Deliberately three.
//
// Enough for every tool that exists, and small enough that the validator is
// readable in one sitting. A tool wanting a nested object is a tool doing two
// things.
type Kind string
const (
KindInt Kind = "integer"
KindString Kind = "string"
KindBool Kind = "boolean"
)
// Field is one argument a tool accepts.
//
// `Max == 0` means unbounded, which is why no field here wants a negative
// range — none do, and a pointer per bound to express "unset" would cost every
// call site clarity to buy a case that has not come up.
type Field struct {
Name string
// What the model reads to decide what to put here. Written for the model,
// not for a developer: "minutes a job may sit unaccepted before it counts
// as stuck" beats "threshold".
Description string
Kind Kind
Required bool
Min, Max int
Default any
}
// Schema is a tool's argument contract.
type Schema struct{ Fields []Field }
var (
ErrUnknownTool = errors.New("no such tool")
ErrNotAllowed = errors.New("this agent may not use that tool")
ErrBadArgument = errors.New("argument is not valid")
ErrNoTenant = errors.New("caller names no tenant")
)
// Validate checks arguments and fills in defaults.
//
// Returns a NEW map rather than editing the caller's, and the returned map is
// what the handler sees. Anything not declared is dropped rather than passed
// through — a handler must never receive a key it did not ask for, or an
// argument the model invented becomes an argument the handler might one day
// start reading.
func (s Schema) Validate(args map[string]any) (map[string]any, error) {
clean := make(map[string]any, len(s.Fields))
for _, field := range s.Fields {
raw, sent := args[field.Name]
if !sent || raw == nil {
if field.Required {
return nil, fmt.Errorf("%w: %s is required", ErrBadArgument, field.Name)
}
if field.Default != nil {
clean[field.Name] = field.Default
}
continue
}
value, err := coerce(field, raw)
if err != nil {
return nil, err
}
clean[field.Name] = value
}
return clean, nil
}
// coerce turns what arrived into what the field declared.
//
// JSON numbers arrive as float64 whatever they looked like on the wire, so an
// integer field has to accept one and check it is whole. Reading it as an int
// directly would fail every call made over HTTP, which is all of them.
func coerce(field Field, raw any) (any, error) {
switch field.Kind {
case KindInt:
var n int
switch v := raw.(type) {
case int:
n = v
case int64:
n = int(v)
case float64:
if v != float64(int(v)) {
return nil, fmt.Errorf("%w: %s must be a whole number", ErrBadArgument, field.Name)
}
n = int(v)
default:
return nil, fmt.Errorf("%w: %s must be a number", ErrBadArgument, field.Name)
}
if n < field.Min {
return nil, fmt.Errorf("%w: %s must be at least %d", ErrBadArgument, field.Name, field.Min)
}
if field.Max > 0 && n > field.Max {
return nil, fmt.Errorf("%w: %s must be at most %d", ErrBadArgument, field.Name, field.Max)
}
return n, nil
case KindString:
text, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("%w: %s must be text", ErrBadArgument, field.Name)
}
if field.Max > 0 && len(text) > field.Max {
return nil, fmt.Errorf("%w: %s is longer than %d characters", ErrBadArgument, field.Name, field.Max)
}
return text, nil
case KindBool:
flag, ok := raw.(bool)
if !ok {
return nil, fmt.Errorf("%w: %s must be true or false", ErrBadArgument, field.Name)
}
return flag, nil
}
return nil, fmt.Errorf("%w: %s has no type", ErrBadArgument, field.Name)
}
// JSONSchema renders the contract in the form a model and MCP both expect.
//
// Kept as a projection of `Schema` rather than the source of truth, so the
// validator and the description a model is given cannot drift: there is one
// declaration and this is a view of it.
func (s Schema) JSONSchema() map[string]any {
properties := map[string]any{}
required := []string{}
for _, field := range s.Fields {
property := map[string]any{
"type": string(field.Kind),
"description": field.Description,
}
if field.Kind == KindInt {
property["minimum"] = field.Min
if field.Max > 0 {
property["maximum"] = field.Max
}
}
if field.Default != nil {
property["default"] = field.Default
}
properties[field.Name] = property
if field.Required {
required = append(required, field.Name)
}
}
sort.Strings(required)
schema := map[string]any{
"type": "object",
"properties": properties,
// The model may not invent arguments. A tool that tolerated extras
// would make the schema a suggestion.
"additionalProperties": false,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
// Caller is the verified session a tool runs on behalf of.
//
// Built from `middleware.WebAuth`'s claims and never from anything the model
// said. That is the whole arrangement: the model chooses the tool and the
// arguments, and has no say at all in whose data it reads.
type Caller struct {
Userid int
Tenantid int
Locationid int
// Nearle staff, from `app_users.issuperadmin` — the only thing that reads
// across tenants. NOT a role: `app_roles` calls roleid 1 "Super admin" and
// tenant onboarding wrote 1 for every shop owner.
Superadmin bool
}
// Request is what a handler receives.
type Request struct {
Args map[string]any
Caller Caller
}
// Int reads a validated integer argument.
//
// No error return, on purpose: by the time a handler runs, the schema has
// already refused anything that is not an int in range, so a second check here
// would be unreachable code that still has to be read.
func (r Request) Int(name string) int {
if v, ok := r.Args[name].(int); ok {
return v
}
return 0
}
func (r Request) String(name string) string {
if v, ok := r.Args[name].(string); ok {
return v
}
return ""
}
func (r Request) Bool(name string) bool {
if v, ok := r.Args[name].(bool); ok {
return v
}
return false
}
// Result is what a tool answers with.
//
// Rows, never prose. The handler returns structured data and the model does the
// phrasing; a handler that wrote sentences would mean two layers formatting the
// same fact, and they drift.
type Result struct {
// Concrete typed slices, marshalled by the caller. `any` here so handlers
// stay typed rather than every one of them building maps.
Rows any
Count int
// True when there were more rows than were returned, with `Note` saying so
// in words. An empty answer and a capped answer look identical to a model,
// and it will describe both as "none".
Truncated bool
Note string
// The console route showing the same rows, so an answer can carry a link to
// what proves it. Buddy states a conclusion; this is where a person checks.
Source string
// What the answer covers — "all branches", or a named one. A tool that
// omits it lets an answer about one branch be read as an answer about all.
Scope string
}
// Agent is a caller's allow-list.
//
// The registry refuses a tool an agent has not named, rather than the model
// declining to call it. A prompt is a request; this is a rule.
type Agent struct {
Name string
Tools []string
}
func (a Agent) Allows(tool string) bool {
for _, name := range a.Tools {
if name == tool {
return true
}
}
return false
}
// Tool is one thing an agent can do.
type Tool struct {
Name string
// What the model reads to choose between tools. The most load-bearing
// string in the package: a vague one produces a model that calls the wrong
// tool and explains the wrong number confidently.
Description string
Scope Scope
Schema Schema
Handler func(ctx context.Context, req Request) (Result, error)
}
// Registry holds the tools and is the only way to reach one.
type Registry struct {
tools map[string]Tool
audit AuditSink
now func() time.Time
}
func New(audit AuditSink) *Registry {
if audit == nil {
audit = DiscardAudit{}
}
return &Registry{tools: map[string]Tool{}, audit: audit, now: time.Now}
}
// Register adds a tool. Duplicate names are refused rather than overwritten:
// silently replacing a tool is how a permission check disappears.
func (r *Registry) Register(t Tool) error {
if t.Name == "" {
return errors.New("a tool needs a name")
}
if t.Handler == nil {
return fmt.Errorf("tool %q has no handler", t.Name)
}
if t.Description == "" {
return fmt.Errorf("tool %q has no description; a model cannot choose it", t.Name)
}
if _, taken := r.tools[t.Name]; taken {
return fmt.Errorf("tool %q is already registered", t.Name)
}
r.tools[t.Name] = t
return nil
}
// Definitions describes the tools one agent may use, for a model or for MCP.
//
// Built from the agent's allow-list rather than from everything registered, so
// a model is never told about a tool it would then be refused — which reads to
// a model as a malfunction and to a person as the assistant being broken.
func (r *Registry) Definitions(agent Agent) []map[string]any {
names := make([]string, 0, len(agent.Tools))
for _, name := range agent.Tools {
if _, ok := r.tools[name]; ok {
names = append(names, name)
}
}
sort.Strings(names)
out := make([]map[string]any, 0, len(names))
for _, name := range names {
tool := r.tools[name]
out = append(out, map[string]any{
"name": tool.Name,
"description": tool.Description,
"input_schema": tool.Schema.JSONSchema(),
})
}
return out
}
// Call is the one entry point, and it does five things in this order:
// find the tool, check the agent may use it, validate the arguments, confirm
// the caller is scoped to something, and run the handler — recording exactly
// one audit row whatever happens, including every refusal.
//
// The order matters. Arguments are validated before the handler sees them so a
// handler never defends itself, and the caller is checked before the handler
// runs so a tool cannot forget to.
func (r *Registry) Call(ctx context.Context, agent Agent, name string, args map[string]any, caller Caller) (Result, error) {
started := r.now()
entry := AuditEntry{
At: started,
Agent: agent.Name,
Tool: name,
Userid: caller.Userid,
Tenantid: caller.Tenantid,
Args: args,
}
finish := func(result Result, outcome string, detail string, err error) (Result, error) {
entry.Outcome = outcome
entry.Detail = detail
entry.Rows = result.Count
entry.Took = r.now().Sub(started)
r.audit.Write(ctx, entry)
return result, err
}
tool, known := r.tools[name]
if !known {
return finish(Result{}, OutcomeRefused, "unknown tool", fmt.Errorf("%w: %s", ErrUnknownTool, name))
}
entry.Scope = string(tool.Scope)
if !agent.Allows(name) {
return finish(Result{}, OutcomeRefused, "not on the agent's allow-list", fmt.Errorf("%w: %s cannot use %s", ErrNotAllowed, agent.Name, name))
}
clean, err := tool.Schema.Validate(args)
if err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
entry.Args = clean
// A caller scoped to nothing must not be treated as a caller scoped to
// everything. Go's zero value is 0, so an unset tenant and a platform
// account look identical unless staff status is asked for separately.
if caller.Tenantid <= 0 && !caller.Superadmin {
return finish(Result{}, OutcomeRefused, "no tenant on the caller", ErrNoTenant)
}
result, err := tool.Handler(ctx, Request{Args: clean, Caller: caller})
if err != nil {
return finish(Result{}, OutcomeFailed, err.Error(), err)
}
return finish(result, OutcomeOK, "", nil)
}

View File

@@ -0,0 +1,327 @@
package tools
import (
"context"
"errors"
"testing"
"time"
)
// The registry is the only way to reach a tool, so these are the checks that
// stand between a model and the data. Each one is a thing the model could ask
// for and must not get.
func okTool(name string) Tool {
return Tool{
Name: name,
Description: "a tool, for testing",
Scope: ScopeRead,
Schema: Schema{Fields: []Field{{
Name: "limit", Description: "how many", Kind: KindInt, Min: 1, Max: 50, Default: 10,
}}},
Handler: func(_ context.Context, req Request) (Result, error) {
return Result{Rows: []int{1, 2}, Count: 2, Scope: "all branches"}, nil
},
}
}
func registryWith(t *testing.T, tools ...Tool) (*Registry, *CollectAudit) {
t.Helper()
audit := &CollectAudit{}
r := New(audit)
for _, tool := range tools {
if err := r.Register(tool); err != nil {
t.Fatalf("registering %s: %v", tool.Name, err)
}
}
return r, audit
}
var anyone = Caller{Userid: 904, Tenantid: 1147}
/* ── The five jobs ─────────────────────────────────────────────────────── */
func TestAToolRunsAndReturnsRows(t *testing.T) {
r, _ := registryWith(t, okTool("thing"))
agent := Agent{Name: "orders", Tools: []string{"thing"}}
result, err := r.Call(context.Background(), agent, "thing", nil, anyone)
if err != nil {
t.Fatalf("calling: %v", err)
}
if result.Count != 2 {
t.Fatalf("rows lost: %d", result.Count)
}
}
func TestAnUnknownToolIsRefused(t *testing.T) {
r, _ := registryWith(t, okTool("thing"))
agent := Agent{Name: "orders", Tools: []string{"thing"}}
_, err := r.Call(context.Background(), agent, "invented", nil, anyone)
if !errors.Is(err, ErrUnknownTool) {
t.Fatalf("a tool the model made up was not refused: %v", err)
}
}
func TestAToolOffTheAllowListIsRefusedByTheRegistryNotTheModel(t *testing.T) {
// The whole point of the allow-list: a prompt is a request, this is a rule.
r, _ := registryWith(t, okTool("thing"), okTool("other"))
agent := Agent{Name: "orders", Tools: []string{"thing"}}
_, err := r.Call(context.Background(), agent, "other", nil, anyone)
if !errors.Is(err, ErrNotAllowed) {
t.Fatalf("an agent reached a tool it does not name: %v", err)
}
}
func TestBadArgumentsNeverReachTheHandler(t *testing.T) {
reached := false
tool := okTool("thing")
tool.Handler = func(context.Context, Request) (Result, error) {
reached = true
return Result{}, nil
}
r, _ := registryWith(t, tool)
agent := Agent{Name: "orders", Tools: []string{"thing"}}
_, err := r.Call(context.Background(), agent, "thing", map[string]any{"limit": 999}, anyone)
if !errors.Is(err, ErrBadArgument) {
t.Fatalf("an out-of-range argument was accepted: %v", err)
}
if reached {
t.Fatal("the handler ran on arguments the schema refused")
}
}
func TestACallerScopedToNothingIsNotACallerScopedToEverything(t *testing.T) {
// Go's zero value is 0, so an unset tenant and a platform account look
// identical unless staff status is asked for separately.
r, _ := registryWith(t, okTool("thing"))
agent := Agent{Name: "orders", Tools: []string{"thing"}}
_, err := r.Call(context.Background(), agent, "thing", nil, Caller{Userid: 904})
if !errors.Is(err, ErrNoTenant) {
t.Fatalf("a caller with no tenant was let through: %v", err)
}
if _, err := r.Call(context.Background(), agent, "thing", nil, Caller{Userid: 12, Superadmin: true}); err != nil {
t.Fatalf("staff were refused: %v", err)
}
}
/* ── Arguments ─────────────────────────────────────────────────────────── */
func TestDefaultsAreFilledIn(t *testing.T) {
var seen Request
tool := okTool("thing")
tool.Handler = func(_ context.Context, req Request) (Result, error) {
seen = req
return Result{}, nil
}
r, _ := registryWith(t, tool)
if _, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", nil, anyone); err != nil {
t.Fatalf("calling: %v", err)
}
if seen.Int("limit") != 10 {
t.Fatalf("the default did not arrive: %d", seen.Int("limit"))
}
}
func TestAJSONNumberIsAcceptedAsAnInteger(t *testing.T) {
// Every argument arrives over HTTP, so an integer field that only accepted
// Go ints would refuse every real call.
var seen Request
tool := okTool("thing")
tool.Handler = func(_ context.Context, req Request) (Result, error) {
seen = req
return Result{}, nil
}
r, _ := registryWith(t, tool)
if _, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", map[string]any{"limit": float64(20)}, anyone); err != nil {
t.Fatalf("a JSON number was refused: %v", err)
}
if seen.Int("limit") != 20 {
t.Fatalf("the value did not survive: %d", seen.Int("limit"))
}
}
func TestAFractionIsNotAWholeNumber(t *testing.T) {
r, _ := registryWith(t, okTool("thing"))
_, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", map[string]any{"limit": 2.5}, anyone)
if !errors.Is(err, ErrBadArgument) {
t.Fatalf("2.5 was accepted as a count: %v", err)
}
}
func TestAnInventedArgumentIsDroppedNotPassedOn(t *testing.T) {
// A handler must never receive a key it did not declare, or an argument the
// model made up becomes one a handler might later start reading.
var seen Request
tool := okTool("thing")
tool.Handler = func(_ context.Context, req Request) (Result, error) {
seen = req
return Result{}, nil
}
r, _ := registryWith(t, tool)
args := map[string]any{"limit": 5, "tenantid": 916, "where": "1=1"}
if _, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", args, anyone); err != nil {
t.Fatalf("calling: %v", err)
}
if _, present := seen.Args["tenantid"]; present {
t.Fatal("the model got to name a tenant")
}
if _, present := seen.Args["where"]; present {
t.Fatal("the model got to pass a where clause")
}
}
/* ── Registration ──────────────────────────────────────────────────────── */
func TestAToolCannotBeSilentlyReplaced(t *testing.T) {
// Overwriting a registered tool is how a permission check disappears.
r, _ := registryWith(t, okTool("thing"))
if err := r.Register(okTool("thing")); err == nil {
t.Fatal("a second tool took the same name")
}
}
func TestAToolWithoutADescriptionIsRefused(t *testing.T) {
// A model chooses between tools by their descriptions. One without is a
// tool that gets called for the wrong question.
r := New(nil)
tool := okTool("thing")
tool.Description = ""
if err := r.Register(tool); err == nil {
t.Fatal("a tool with no description was registered")
}
}
/* ── What the model is told ────────────────────────────────────────────── */
func TestAnAgentIsOnlyToldAboutToolsItMayUse(t *testing.T) {
// Describing a tool the agent would then be refused reads to a model as a
// malfunction, and to a person as the assistant being broken.
r, _ := registryWith(t, okTool("thing"), okTool("other"))
defs := r.Definitions(Agent{Name: "orders", Tools: []string{"thing"}})
if len(defs) != 1 || defs[0]["name"] != "thing" {
t.Fatalf("the agent was told about the wrong tools: %v", defs)
}
}
func TestTheSchemaForbidsInventedArguments(t *testing.T) {
r, _ := registryWith(t, okTool("thing"))
defs := r.Definitions(Agent{Name: "orders", Tools: []string{"thing"}})
schema, _ := defs[0]["input_schema"].(map[string]any)
if schema["additionalProperties"] != false {
t.Fatal("the schema lets the model add its own arguments")
}
}
/* ── The audit trail ───────────────────────────────────────────────────── */
func TestEveryCallLeavesExactlyOneRow(t *testing.T) {
r, audit := registryWith(t, okTool("thing"))
agent := Agent{Name: "orders", Tools: []string{"thing"}}
_, _ = r.Call(context.Background(), agent, "thing", nil, anyone)
if len(audit.Entries) != 1 {
t.Fatalf("a successful call wrote %d rows", len(audit.Entries))
}
entry, _ := audit.Last()
if entry.Outcome != OutcomeOK || entry.Tool != "thing" || entry.Tenantid != 1147 {
t.Fatalf("the row does not describe the call: %+v", entry)
}
}
func TestARefusalIsAudited(t *testing.T) {
// The refusals are the interesting ones. A trail of successes answers
// "did anything try to read another tenant?" with silence, which reads the
// same as "no".
r, audit := registryWith(t, okTool("thing"), okTool("other"))
_, _ = r.Call(context.Background(), Agent{Name: "orders", Tools: []string{"thing"}}, "other", nil, anyone)
entry, ok := audit.Last()
if !ok || entry.Outcome != OutcomeRefused {
t.Fatalf("a refusal left no trace: %+v", entry)
}
if entry.Detail == "" {
t.Fatal("the refusal does not say why")
}
}
func TestABrokenToolIsFailedNotRefused(t *testing.T) {
// Collapsing the two hides a broken tool inside a count of things working
// as designed.
tool := okTool("thing")
tool.Handler = func(context.Context, Request) (Result, error) {
return Result{}, errors.New("the database is down")
}
r, audit := registryWith(t, tool)
_, err := r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing", nil, anyone)
if err == nil {
t.Fatal("a broken handler reported success")
}
entry, _ := audit.Last()
if entry.Outcome != OutcomeFailed {
t.Fatalf("a handler error was recorded as %q", entry.Outcome)
}
}
func TestTheAuditKeepsWhatRanNotWhatWasSent(t *testing.T) {
// Defaults applied, invented keys dropped. What actually executed is the
// thing worth being able to read back.
r, audit := registryWith(t, okTool("thing"))
_, _ = r.Call(context.Background(), Agent{Name: "a", Tools: []string{"thing"}}, "thing",
map[string]any{"tenantid": 916}, anyone)
entry, _ := audit.Last()
if _, present := entry.Args["tenantid"]; present {
t.Fatal("the audit kept an argument the handler never saw")
}
if entry.Args["limit"] != 10 {
t.Fatalf("the applied default is missing from the trail: %+v", entry.Args)
}
}
func TestAnAuditLineIsStableBetweenIdenticalCalls(t *testing.T) {
// Go randomises map iteration, so without sorting the same call logs
// differently every time and a grep for one of them finds one of them.
entry := AuditEntry{
At: time.Now(), Agent: "orders", Tool: "thing", Userid: 904, Tenantid: 1147,
Args: map[string]any{"b": 2, "a": 1, "c": 3}, Outcome: OutcomeOK,
}
first := entry.Line()
for range 20 {
if entry.Line() != first {
t.Fatalf("two renderings of one entry differ:\n%s\n%s", first, entry.Line())
}
}
}
func TestAnAuditLineSaysWhenThereWasNoTenant(t *testing.T) {
// Explicitly, rather than by omission: no tenant on an assistant call is
// either staff or a bug, and both are worth being able to search for.
entry := AuditEntry{Agent: "orders", Tool: "thing", Userid: 12, Outcome: OutcomeOK}
if got := entry.Line(); !contains(got, "tenant=none") {
t.Fatalf("a tenantless call is invisible in the log: %s", got)
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}

View File

@@ -0,0 +1,256 @@
package tools
import (
"context"
"fmt"
"sort"
"strings"
"time"
"nearle/models"
)
// "Which orders are stuck?" — the first tool, and a real one.
//
// A delivery that is still `pending` some time after it was handed out is a job
// nobody has picked up. The rider may not have seen it, may have no device, may
// have put the phone down. The console cannot tell which, and does not need to:
// the wait itself is the fact worth surfacing, and every one of these is a
// customer waiting without knowing why.
//
// ── Derived, never remembered ───────────────────────────────────────────────
//
// Nothing stores "this job went unaccepted". It is computed from `assigntime`
// and `orderstatus`, both of which every delivery read already returns, so the
// answer does not depend on anybody having been watching when it happened.
//
// ── Why the two thresholds ──────────────────────────────────────────────────
//
// Ten minutes is worth a look; twenty-five needs somebody now. A single
// threshold either cries wolf at three minutes — which trains people to ignore
// it, and an ignored flag is worse than none — or stays silent until the
// customer has already called.
const (
// StuckLookMinutes is when a wait becomes worth a glance.
StuckLookMinutes = 10
// StuckNowMinutes is when it needs a person.
StuckNowMinutes = 25
// stuckMaxRows caps one answer. A model handed four hundred rows summarises
// them into a sentence nobody can check; a dispatcher can act on ten.
stuckMaxRows = 50
)
// DeliveryReader is the one thing this tool needs from the rest of the app.
//
// Narrowed to a single method so the tool can be tested without a database, and
// so it cannot quietly grow a second dependency. The real implementation is
// `services.DeliveriesService`.
type DeliveryReader interface {
GetDeliveries(input models.DeliveryQuery) []models.Deliveryinfo
}
// StuckOrder is one row of the answer.
//
// Field names are what the model will read back to a person, so they say what
// they mean: `WaitingMinutes`, not `delta`.
type StuckOrder struct {
Deliveryid int `json:"deliveryid"`
Orderid string `json:"orderid"`
Rider string `json:"rider,omitempty"`
Branch string `json:"branch,omitempty"`
Customer string `json:"customer,omitempty"`
AssignedAt string `json:"assigned_at"`
WaitingMinutes int `json:"waiting_minutes"`
// "look" or "now" — which bucket the wait falls in. Returned rather than
// left to the model to work out from the number, so the threshold is
// decided in one place and cannot be re-invented in a sentence.
Urgency string `json:"urgency"`
Action string `json:"action"`
}
// StuckOrders builds the tool.
//
// `now` is injected so the tests can ask what the board looked like at a fixed
// instant. Production passes `time.Now`.
func StuckOrders(deliveries DeliveryReader, now func() time.Time) Tool {
if now == nil {
now = time.Now
}
return Tool{
Name: "stuck_orders",
Description: "Deliveries a rider has been given but has not accepted yet, oldest first. " +
"Use for questions about jobs that are stuck, not moving, unaccepted, or riders who have not started. " +
"Returns the wait in minutes and what to do about each one.",
Scope: ScopeRead,
Schema: Schema{Fields: []Field{{
Name: "minutes_waiting",
Description: "Only count jobs unaccepted for at least this many minutes. Defaults to 10.",
Kind: KindInt,
Min: 1,
Max: 720,
Default: StuckLookMinutes,
}}},
Handler: func(_ context.Context, req Request) (Result, error) {
// The tenant comes from the verified session, never from an
// argument. There is deliberately no `tenantid` field on the schema
// above: a tool that accepted one would let the model be talked into
// reading somebody else's shop, and the model is the one part of
// this system that can be argued with.
if req.Caller.Tenantid <= 0 {
return Result{}, fmt.Errorf("stuck_orders needs a tenant; staff must pick one first")
}
threshold := req.Int("minutes_waiting")
if threshold <= 0 {
threshold = StuckLookMinutes
}
rows := deliveries.GetDeliveries(models.DeliveryQuery{
Tenantid: req.Caller.Tenantid,
// The caller's own branch when they have one. A branch user asking
// "what is stuck?" means their shop; an admin with no home branch
// means all of them.
Locationid: req.Caller.Locationid,
Pagesize: 500,
Pageno: 1,
})
at := now()
stuck := make([]StuckOrder, 0, 8)
for _, row := range rows {
waited, ok := unacceptedFor(row, at)
if !ok || waited < time.Duration(threshold)*time.Minute {
continue
}
minutes := int(waited.Minutes())
urgency, action := "look", "Check the rider has seen it."
if minutes >= StuckNowMinutes {
urgency, action = "now", "Call the rider, or give the job to somebody else."
}
stuck = append(stuck, StuckOrder{
Deliveryid: row.Deliveryid,
Orderid: row.Orderid,
// `ridername` is not reliably a name — on tenant 916 every
// rider has delivery statuses in that column too, and for two
// of five the status is the MORE common value. Excluded by
// vocabulary rather than by a hand-written list.
Rider: riderName(row.Ridername),
Branch: row.Locationname,
Customer: row.Deliverycustomer,
AssignedAt: row.Assigntime,
WaitingMinutes: minutes,
Urgency: urgency,
Action: action,
})
}
// Worst first, then longest waiting. This is a worklist, not a log:
// the row to deal with next belongs at the top.
sort.SliceStable(stuck, func(i, j int) bool {
if stuck[i].Urgency != stuck[j].Urgency {
return stuck[i].Urgency == "now"
}
return stuck[i].WaitingMinutes > stuck[j].WaitingMinutes
})
result := Result{
Count: len(stuck),
Source: "/admin/dispatch",
Scope: scopeWords(req.Caller),
}
if len(stuck) > stuckMaxRows {
result.Truncated = true
result.Note = fmt.Sprintf(
"%d jobs are waiting; the %d longest are listed. Say so — do not describe this as the full list.",
len(stuck), stuckMaxRows)
stuck = stuck[:stuckMaxRows]
}
result.Rows = stuck
return result, nil
},
}
}
// unacceptedFor is how long a job has sat with nobody accepting it.
//
// Only `pending` counts. A job the rider accepted, picked up or delivered is
// not stuck however old it is, and `rejected` or `skipped` is a different
// problem with a different answer — those are somebody's to reassign, not to
// chase.
//
// A stamp that will not parse returns false rather than 1970. Reading an
// unparseable `assigntime` as the epoch would report every such row as fifty
// years late, which is the kind of number that gets a whole screen ignored.
func unacceptedFor(row models.Deliveryinfo, now time.Time) (time.Duration, bool) {
if strings.ToLower(strings.TrimSpace(row.Orderstatus)) != "pending" {
return 0, false
}
assigned, ok := parseStamp(row.Assigntime)
if !ok {
return 0, false
}
waited := now.Sub(assigned)
if waited < 0 {
// A clock ahead of ours, not a job from the future.
return 0, false
}
return waited, true
}
// parseStamp reads `assigntime` as the writer actually writes it.
//
// Local wall-clock with no zone — `2026-09-23 14:05:31` — which is what
// `stampNow` produces. Parsed in the server's own location rather than UTC,
// because reading local digits as UTC would put every job five and a half hours
// out in India and turn a fresh assignment into a four-hour wait.
func parseStamp(raw string) (time.Time, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return time.Time{}, false
}
for _, layout := range []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
time.RFC3339,
} {
if at, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return at, true
}
}
return time.Time{}, false
}
// riderName keeps a name and drops a status wearing one.
//
// `deliveries.ridername` carries both. Measured on tenant 916: rider 897 has
// "Varun" 69 times and "delivered" 75, so neither "first non-empty" nor "most
// common" finds the name. The statuses are excluded by vocabulary, so a status
// added to the ladder is excluded the same day.
func riderName(raw string) string {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
return ""
}
for _, status := range []string{
"pending", "accepted", "arrived", "picked", "active",
"skipped", "rejected", "delivered", "cancelled", "waiting",
} {
if name == status {
return ""
}
}
return strings.TrimSpace(raw)
}
// scopeWords says what the answer covers, in words a person would use.
func scopeWords(caller Caller) string {
if caller.Locationid > 0 {
return "this branch"
}
return "all branches"
}

View File

@@ -0,0 +1,305 @@
package tools
import (
"context"
"testing"
"time"
"nearle/models"
)
var stuckNow = time.Date(2026, 9, 23, 14, 0, 0, 0, time.Local)
// fakeDeliveries stands in for the deliveries service, and records what it was
// asked — the query matters as much as the answer, because that is where the
// tenant scope either is or is not.
type fakeDeliveries struct {
rows []models.Deliveryinfo
last models.DeliveryQuery
}
func (f *fakeDeliveries) GetDeliveries(input models.DeliveryQuery) []models.Deliveryinfo {
f.last = input
return f.rows
}
// assignedMinutesAgo writes the stamp the way `stampNow` does: local
// wall-clock, no zone.
func assignedMinutesAgo(n int) string {
return stuckNow.Add(-time.Duration(n) * time.Minute).Format("2006-01-02 15:04:05")
}
func job(over models.Deliveryinfo) models.Deliveryinfo {
if over.Orderstatus == "" {
over.Orderstatus = "pending"
}
if over.Assigntime == "" {
over.Assigntime = assignedMinutesAgo(30)
}
return over
}
func runStuck(t *testing.T, rows []models.Deliveryinfo, args map[string]any, caller Caller) (Result, *fakeDeliveries) {
t.Helper()
deliveries := &fakeDeliveries{rows: rows}
r := New(nil)
if err := r.Register(StuckOrders(deliveries, func() time.Time { return stuckNow })); err != nil {
t.Fatalf("registering: %v", err)
}
result, err := r.Call(context.Background(), Agent{Name: "orders", Tools: []string{"stuck_orders"}}, "stuck_orders", args, caller)
if err != nil {
t.Fatalf("calling: %v", err)
}
return result, deliveries
}
func rowsOf(t *testing.T, result Result) []StuckOrder {
t.Helper()
rows, ok := result.Rows.([]StuckOrder)
if !ok {
t.Fatalf("rows are not stuck orders: %T", result.Rows)
}
return rows
}
/* ── The tenant is never the model's to choose ─────────────────────────── */
func TestTheTenantComesFromTheSessionNotTheArguments(t *testing.T) {
// The single most important property of the whole registry. The model picks
// the tool and the arguments; it has no say in whose data is read.
_, deliveries := runStuck(t, nil, map[string]any{"tenantid": 916}, Caller{Userid: 904, Tenantid: 1147})
if deliveries.last.Tenantid != 1147 {
t.Fatalf("the query ran against tenant %d", deliveries.last.Tenantid)
}
}
func TestTheToolDoesNotEvenAcceptATenantArgument(t *testing.T) {
// Belt and braces: the schema must not have the field at all, so there is
// nothing to argue the model into filling in.
tool := StuckOrders(&fakeDeliveries{}, nil)
for _, field := range tool.Schema.Fields {
if field.Name == "tenantid" || field.Name == "locationid" {
t.Fatalf("the schema offers %q for the model to set", field.Name)
}
}
}
func TestABranchUserIsScopedToTheirBranch(t *testing.T) {
_, deliveries := runStuck(t, nil, nil, Caller{Userid: 904, Tenantid: 1147, Locationid: 1172})
if deliveries.last.Locationid != 1172 {
t.Fatalf("a branch user read branch %d", deliveries.last.Locationid)
}
}
func TestStaffMustPickATenantFirst(t *testing.T) {
// A platform account passes the registry's caller check but cannot ask this
// question of "everyone" — the answer would span merchants.
deliveries := &fakeDeliveries{}
r := New(nil)
_ = r.Register(StuckOrders(deliveries, func() time.Time { return stuckNow }))
_, err := r.Call(context.Background(), Agent{Name: "orders", Tools: []string{"stuck_orders"}},
"stuck_orders", nil, Caller{Userid: 12, Superadmin: true})
if err == nil {
t.Fatal("staff read stuck orders across every tenant at once")
}
}
/* ── What counts as stuck ──────────────────────────────────────────────── */
func TestOnlyPendingJobsAreStuck(t *testing.T) {
// A job the rider accepted, picked up or delivered is not stuck however old
// it is. Rejected and skipped are a different problem with a different fix.
rows := []models.Deliveryinfo{
job(models.Deliveryinfo{Deliveryid: 1, Orderstatus: "pending"}),
job(models.Deliveryinfo{Deliveryid: 2, Orderstatus: "accepted"}),
job(models.Deliveryinfo{Deliveryid: 3, Orderstatus: "picked"}),
job(models.Deliveryinfo{Deliveryid: 4, Orderstatus: "delivered"}),
job(models.Deliveryinfo{Deliveryid: 5, Orderstatus: "rejected"}),
job(models.Deliveryinfo{Deliveryid: 6, Orderstatus: "skipped"}),
}
result, _ := runStuck(t, rows, nil, anyone)
if result.Count != 1 || rowsOf(t, result)[0].Deliveryid != 1 {
t.Fatalf("expected only the pending job: %+v", result.Rows)
}
}
func TestTheCasingTheRiderAppActuallyWrites(t *testing.T) {
// Fiesta stores status as free text and the casing varies between writers.
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Orderstatus: "Pending"})}
result, _ := runStuck(t, rows, nil, anyone)
if result.Count != 1 {
t.Fatal("a capitalised status stopped counting")
}
}
func TestAFewMinutesOfSilenceIsOrdinary(t *testing.T) {
// Flagging this would train people to ignore the flag, and an ignored flag
// is worse than none.
rows := []models.Deliveryinfo{
job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(3)}),
job(models.Deliveryinfo{Deliveryid: 2, Assigntime: assignedMinutesAgo(9)}),
}
result, _ := runStuck(t, rows, nil, anyone)
if result.Count != 0 {
t.Fatalf("a nine-minute wait was reported as stuck: %+v", result.Rows)
}
}
func TestTenMinutesIsALookAndTwentyFiveNeedsSomebody(t *testing.T) {
rows := []models.Deliveryinfo{
job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(12)}),
job(models.Deliveryinfo{Deliveryid: 2, Assigntime: assignedMinutesAgo(40)}),
}
result, _ := runStuck(t, rows, nil, anyone)
got := rowsOf(t, result)
// Worst first: the forty-minute job leads.
if got[0].Deliveryid != 2 || got[0].Urgency != "now" {
t.Fatalf("the urgent job is not first: %+v", got)
}
if got[1].Urgency != "look" {
t.Fatalf("a twelve-minute wait was not a look: %+v", got[1])
}
if got[0].Action == got[1].Action {
t.Fatal("both buckets suggest the same action, so the bucket says nothing")
}
}
func TestTheThresholdCanBeRaised(t *testing.T) {
rows := []models.Deliveryinfo{
job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(12)}),
job(models.Deliveryinfo{Deliveryid: 2, Assigntime: assignedMinutesAgo(40)}),
}
result, _ := runStuck(t, rows, map[string]any{"minutes_waiting": 30}, anyone)
if result.Count != 1 || rowsOf(t, result)[0].Deliveryid != 2 {
t.Fatalf("the threshold was ignored: %+v", result.Rows)
}
}
func TestTheWaitIsReportedInMinutes(t *testing.T) {
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(40)})}
result, _ := runStuck(t, rows, nil, anyone)
if got := rowsOf(t, result)[0].WaitingMinutes; got != 40 {
t.Fatalf("waiting minutes reported as %d", got)
}
}
/* ── Stamps that cannot be read ────────────────────────────────────────── */
func TestAnUnreadableAssigntimeIsSkippedNotDatedTo1970(t *testing.T) {
// Reading it as the epoch would report the row as fifty years late, which
// is the kind of number that gets a whole screen ignored.
// Built without `job()`, which fills a default stamp in — the helper would
// hand the parser a valid date and the test would pass without testing.
rows := []models.Deliveryinfo{
{Deliveryid: 1, Orderstatus: "pending", Assigntime: ""},
{Deliveryid: 2, Orderstatus: "pending", Assigntime: "not a date"},
{Deliveryid: 3, Orderstatus: "pending", Assigntime: " "},
}
result, _ := runStuck(t, rows, nil, anyone)
if result.Count != 0 {
t.Fatalf("an unreadable stamp produced a row: %+v", result.Rows)
}
}
func TestALocalStampIsNotReadAsUTC(t *testing.T) {
// `assigntime` is local wall-clock with no zone. Read as UTC it would be
// five and a half hours out in India, turning a fresh assignment into a
// four-hour wait.
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(30)})}
result, _ := runStuck(t, rows, nil, anyone)
if got := rowsOf(t, result)[0].WaitingMinutes; got != 30 {
t.Fatalf("a local stamp read as %d minutes instead of 30", got)
}
}
func TestAJobFromTheFutureIsAClockNotAWait(t *testing.T) {
rows := []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1, Assigntime: assignedMinutesAgo(-20)})}
result, _ := runStuck(t, rows, nil, anyone)
if result.Count != 0 {
t.Fatalf("a stamp in the future was reported as a wait: %+v", result.Rows)
}
}
/* ── The rider's name ──────────────────────────────────────────────────── */
func TestAStatusInTheRiderColumnIsNotAName(t *testing.T) {
// Measured on tenant 916: rider 897 carries "Varun" 69 times and
// "delivered" 75, so neither "first non-empty" nor "most common" finds the
// name. A model handed "delivered" would tell somebody to call a rider
// called Delivered.
rows := []models.Deliveryinfo{
job(models.Deliveryinfo{Deliveryid: 1, Ridername: "delivered"}),
job(models.Deliveryinfo{Deliveryid: 2, Ridername: "Varun"}),
}
result, _ := runStuck(t, rows, nil, anyone)
got := rowsOf(t, result)
byID := map[int]string{}
for _, row := range got {
byID[row.Deliveryid] = row.Rider
}
if byID[1] != "" {
t.Fatalf("a status was reported as a rider: %q", byID[1])
}
if byID[2] != "Varun" {
t.Fatalf("a real name was dropped: %q", byID[2])
}
}
/* ── Saying when the answer is partial ─────────────────────────────────── */
func TestACappedAnswerSaysSo(t *testing.T) {
// An empty answer and a capped answer look identical to a model, and it
// will describe both as "none".
rows := make([]models.Deliveryinfo, 0, 60)
for i := range 60 {
rows = append(rows, job(models.Deliveryinfo{Deliveryid: i + 1, Assigntime: assignedMinutesAgo(30 + i)}))
}
result, _ := runStuck(t, rows, nil, anyone)
if !result.Truncated {
t.Fatal("sixty rows came back as a complete answer")
}
if result.Note == "" {
t.Fatal("the cap is not explained in words the model will repeat")
}
if len(rowsOf(t, result)) != stuckMaxRows {
t.Fatalf("the cap did not apply: %d rows", len(rowsOf(t, result)))
}
if result.Count != 60 {
t.Fatalf("the true total was lost: %d", result.Count)
}
}
func TestAnEmptyBoardIsAnAnswer(t *testing.T) {
result, _ := runStuck(t, nil, nil, anyone)
if result.Count != 0 || result.Truncated {
t.Fatalf("an empty board was not answered cleanly: %+v", result)
}
if result.Scope == "" {
t.Fatal("even an empty answer must say what it covered")
}
}
/* ── Evidence ──────────────────────────────────────────────────────────── */
func TestTheAnswerCarriesWhereToCheckIt(t *testing.T) {
// Buddy states a conclusion; this is where a person goes to see the rows.
result, _ := runStuck(t, []models.Deliveryinfo{job(models.Deliveryinfo{Deliveryid: 1})}, nil, anyone)
if result.Source == "" {
t.Fatal("the answer links to nothing")
}
}
func TestTheScopeIsStatedSoOneBranchIsNotReadAsAll(t *testing.T) {
all, _ := runStuck(t, nil, nil, Caller{Userid: 904, Tenantid: 1147})
one, _ := runStuck(t, nil, nil, Caller{Userid: 904, Tenantid: 1147, Locationid: 1172})
if all.Scope == one.Scope {
t.Fatalf("one branch and all branches report the same scope: %q", all.Scope)
}
}