diff --git a/facade/container.go b/facade/container.go index 058eace..acb55a2 100644 --- a/facade/container.go +++ b/facade/container.go @@ -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, } } diff --git a/services/tools/audit.go b/services/tools/audit.go new file mode 100644 index 0000000..2897a5f --- /dev/null +++ b/services/tools/audit.go @@ -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 +} diff --git a/services/tools/registry.go b/services/tools/registry.go new file mode 100644 index 0000000..5ae52ff --- /dev/null +++ b/services/tools/registry.go @@ -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) +} diff --git a/services/tools/registry_test.go b/services/tools/registry_test.go new file mode 100644 index 0000000..533ce34 --- /dev/null +++ b/services/tools/registry_test.go @@ -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 + })() +} diff --git a/services/tools/stuckorders.go b/services/tools/stuckorders.go new file mode 100644 index 0000000..ebe415f --- /dev/null +++ b/services/tools/stuckorders.go @@ -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" +} diff --git a/services/tools/stuckorders_test.go b/services/tools/stuckorders_test.go new file mode 100644 index 0000000..ad05f07 --- /dev/null +++ b/services/tools/stuckorders_test.go @@ -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) + } +}