273 lines
9.3 KiB
Go
273 lines
9.3 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"nearle/models"
|
|
)
|
|
|
|
// The sweep: every tool that ships, held to the same rules.
|
|
//
|
|
// Phase 5's measure is that a merchant's question cannot return another
|
|
// merchant's row. The per-tool tests each prove it for one tool; this proves it
|
|
// for all of them at once, and — more usefully — for the eighth tool somebody
|
|
// adds in a hurry next month. A tool that forgets appears here as a failure
|
|
// rather than as a support ticket.
|
|
|
|
// shipped is one real tool plus a way to ask what tenant its service was given.
|
|
//
|
|
// The recorders differ per tool because the services differ, and writing them
|
|
// out is the honest version: a generic mechanism would need every service to
|
|
// share an interface they have no reason to share.
|
|
type shipped struct {
|
|
tool Tool
|
|
// askedTenant reports the tenant the underlying service was called with,
|
|
// or -1 when the tool never reached its service.
|
|
askedTenant func() int
|
|
}
|
|
|
|
func shippedTools(t *testing.T) []Tool {
|
|
t.Helper()
|
|
out := make([]Tool, 0, 8)
|
|
for _, s := range shippedWithRecorders(t) {
|
|
out = append(out, s.tool)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func shippedWithRecorders(t *testing.T) []shipped {
|
|
t.Helper()
|
|
|
|
corpus, err := LoadHelp()
|
|
if err != nil {
|
|
t.Fatalf("loading help: %v", err)
|
|
}
|
|
|
|
deliveries1 := &fakeDeliveries{}
|
|
deliveries2 := &fakeDeliveries{}
|
|
branches := &fakeBranches{}
|
|
approvals := &fakeApprovals{}
|
|
stocks := &fakeStocks{}
|
|
tills := &fakeTills{}
|
|
revenue := &fakeRevenue{}
|
|
counter := &fakeCounter{}
|
|
// Carries a pending request so the write resolves rather than refusing —
|
|
// the sweep is about scope and shape, not about an empty inbox.
|
|
writes := &writingApprovals{}
|
|
writes.rows = []models.StockRequest{pendingRequest(41, 12, "Rice", "R Mart")}
|
|
|
|
fixed := func() time.Time { return time.Date(2026, 9, 23, 12, 0, 0, 0, time.Local) }
|
|
|
|
return []shipped{
|
|
{StuckOrders(deliveries1, fixed), func() int { return deliveries1.last.Tenantid }},
|
|
{DeliveryProgress(deliveries2), func() int { return deliveries2.last.Tenantid }},
|
|
{BranchPerformance(branches), func() int { return branches.seen }},
|
|
{PendingApprovals(approvals, fixed), func() int { return approvals.seenTenant }},
|
|
{LowStock(stocks), func() int { return atoiOr(stocks.seenTenant, -1) }},
|
|
{TillsNotSyncing(tills), func() int { return -1 }}, // presence is keyed by branch, not tenant
|
|
{SalesByChannel(revenue, counter, fixed), func() int { return revenue.seenTid }},
|
|
// Help reaches no service and holds no shop data — it is the one tool
|
|
// with RequiresNothing, and the sweep checks that separately below.
|
|
{Help(corpus), func() int { return -1 }},
|
|
// The write. Included so it is held to every rule the reads are, and so
|
|
// a second write added later cannot quietly skip the sweep.
|
|
{ApproveStockRequest(writes, writes), func() int { return writes.seenTenant }},
|
|
}
|
|
}
|
|
|
|
func atoiOr(text string, fallback int) int {
|
|
n := 0
|
|
if text == "" {
|
|
return fallback
|
|
}
|
|
for _, r := range text {
|
|
if r < '0' || r > '9' {
|
|
return fallback
|
|
}
|
|
n = n*10 + int(r-'0')
|
|
}
|
|
return n
|
|
}
|
|
|
|
/* ── The property phase 5 is measured on ───────────────────────────────── */
|
|
|
|
func TestNoToolReadsATenantOtherThanTheCallers(t *testing.T) {
|
|
t.Setenv("POS_TOKEN_SECRET", cardSecret)
|
|
// The model is handed every argument it could possibly want, including the
|
|
// one it must never be able to use. Each tool's service must still have been
|
|
// asked only about the caller's own shop.
|
|
const mine = 1147
|
|
const theirs = 916
|
|
|
|
caller := Caller{Userid: 904, Tenantid: mine, Locationid: 1172}
|
|
poison := map[string]any{
|
|
"tenantid": theirs, "tenant_id": theirs, "locationid": 9999,
|
|
"store_id": 9999, "partnerid": theirs, "customerid": theirs,
|
|
"minutes_waiting": 10, "at_or_below": 5, "days": 7,
|
|
"question": "how do I add a cashier", "area": "people", "requestid": 41,
|
|
}
|
|
|
|
for _, s := range shippedWithRecorders(t) {
|
|
r := New(nil)
|
|
if err := r.Register(s.tool); err != nil {
|
|
t.Fatalf("registering %s: %v", s.tool.Name, err)
|
|
}
|
|
agent := Agent{Name: "sweep", Tools: []string{s.tool.Name}}
|
|
|
|
if _, err := r.Call(context.Background(), agent, s.tool.Name, poison, caller); err != nil {
|
|
t.Fatalf("%s refused a legitimate caller: %v", s.tool.Name, err)
|
|
}
|
|
|
|
if asked := s.askedTenant(); asked != -1 && asked != mine {
|
|
t.Fatalf("%s read tenant %d for a caller from %d", s.tool.Name, asked, mine)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNoToolAcceptsAnArgumentThatChoosesWhoseDataIsRead(t *testing.T) {
|
|
// Structural, not behavioural: the schema must not offer the field at all,
|
|
// so there is nothing for a model to be argued into filling in. Register
|
|
// enforces it, and this is the sweep over everything that ships.
|
|
r := New(nil)
|
|
for _, tool := range shippedTools(t) {
|
|
if err := r.Register(tool); err != nil {
|
|
t.Fatalf("%s: %v", tool.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEveryTenantScopedToolRefusesACallerWithNoShop(t *testing.T) {
|
|
// Including staff. A platform account carries no tenant, and every one of
|
|
// these would otherwise run with a zero tenant and return whatever that
|
|
// means to the query underneath.
|
|
staff := Caller{Userid: 12, Superadmin: true}
|
|
|
|
for _, s := range shippedWithRecorders(t) {
|
|
if s.tool.Needs == RequiresNothing {
|
|
continue
|
|
}
|
|
r := New(nil)
|
|
if err := r.Register(s.tool); err != nil {
|
|
t.Fatalf("registering %s: %v", s.tool.Name, err)
|
|
}
|
|
_, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{s.tool.Name}},
|
|
s.tool.Name, map[string]any{"question": "x", "requestid": 41}, staff)
|
|
if !errors.Is(err, ErrNoTenant) {
|
|
t.Fatalf("%s answered a caller with no shop: %v", s.tool.Name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEveryBranchScopedToolRefusesAnAllBranchesCaller(t *testing.T) {
|
|
// A tool that only exists per outlet must say so rather than answering for
|
|
// whichever branch a zero happens to mean.
|
|
admin := Caller{Userid: 904, Tenantid: 1147}
|
|
|
|
found := 0
|
|
for _, s := range shippedWithRecorders(t) {
|
|
if s.tool.Needs != RequiresBranch {
|
|
continue
|
|
}
|
|
found++
|
|
r := New(nil)
|
|
_ = r.Register(s.tool)
|
|
_, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{s.tool.Name}},
|
|
s.tool.Name, map[string]any{"days": 7}, admin)
|
|
if !errors.Is(err, ErrNoTenant) {
|
|
t.Fatalf("%s answered without a branch: %v", s.tool.Name, err)
|
|
}
|
|
}
|
|
if found == 0 {
|
|
t.Fatal("no branch-scoped tools were exercised, so this proves nothing")
|
|
}
|
|
}
|
|
|
|
/* ── Things every tool owes the model and the reader ───────────────────── */
|
|
|
|
func TestEveryToolTellsTheModelWhenToUseIt(t *testing.T) {
|
|
// A model chooses between tools by their descriptions alone. A vague one
|
|
// produces a model that calls the wrong tool and explains the wrong number
|
|
// with total confidence.
|
|
for _, tool := range shippedTools(t) {
|
|
if len(tool.Description) < 60 {
|
|
t.Fatalf("%s has a description too thin to choose by: %q", tool.Name, tool.Description)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNoToolWritesWhenTheModelAsksForIt(t *testing.T) {
|
|
// Every write must have been built through WriteTool, which is the only way
|
|
// to get a propose/execute pair — and Register refuses a ScopeWrite tool
|
|
// without one. A write whose Handler wrote would execute on a model's say
|
|
// so, with nobody asked.
|
|
r := New(nil)
|
|
writes := 0
|
|
for _, tool := range shippedTools(t) {
|
|
if err := r.Register(tool); err != nil {
|
|
t.Fatalf("%s: %v", tool.Name, err)
|
|
}
|
|
if tool.Scope == ScopeWrite {
|
|
writes++
|
|
if tool.propose == nil || tool.execute == nil {
|
|
t.Fatalf("%s is a write with no approval path", tool.Name)
|
|
}
|
|
}
|
|
}
|
|
if writes == 0 {
|
|
t.Fatal("no write tools were exercised, so this proves nothing")
|
|
}
|
|
}
|
|
|
|
func TestTheSweepItselfChangesNothing(t *testing.T) {
|
|
t.Setenv("POS_TOKEN_SECRET", cardSecret)
|
|
// Every other test in this file calls every tool. If a write ever executed
|
|
// on Call, this is the test that notices — and it notices for tools added
|
|
// after today.
|
|
writes := &writingApprovals{}
|
|
writes.rows = []models.StockRequest{pendingRequest(41, 12, "Rice", "R Mart")}
|
|
|
|
r := New(nil)
|
|
tool := ApproveStockRequest(writes, writes)
|
|
_ = r.Register(tool)
|
|
|
|
_, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{tool.Name}},
|
|
tool.Name, map[string]any{"requestid": 41}, Caller{Userid: 904, Tenantid: 1147})
|
|
if err != nil {
|
|
t.Fatalf("proposing: %v", err)
|
|
}
|
|
if len(writes.wrote) != 0 {
|
|
t.Fatalf("calling a write tool wrote to the database: %v", writes.wrote)
|
|
}
|
|
}
|
|
|
|
func TestEveryToolNamesWhereItsRowsCanBeChecked(t *testing.T) {
|
|
// Buddy states conclusions in sentences. The only honest way to present that
|
|
// is beside a link to the page holding the same rows.
|
|
caller := Caller{Userid: 904, Tenantid: 1147, Locationid: 1172}
|
|
|
|
for _, s := range shippedWithRecorders(t) {
|
|
if s.tool.Needs == RequiresNothing {
|
|
continue // the help corpus is not a page of rows
|
|
}
|
|
if s.tool.Scope == ScopeWrite {
|
|
continue // a write answers with a card, not with rows to check
|
|
}
|
|
r := New(nil)
|
|
_ = r.Register(s.tool)
|
|
result, err := r.Call(context.Background(), Agent{Name: "sweep", Tools: []string{s.tool.Name}},
|
|
s.tool.Name, map[string]any{"days": 7}, caller)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", s.tool.Name, err)
|
|
}
|
|
if result.Source == "" {
|
|
t.Fatalf("%s answers with nowhere to check it", s.tool.Name)
|
|
}
|
|
if result.Scope == "" {
|
|
t.Fatalf("%s does not say what its answer covered", s.tool.Name)
|
|
}
|
|
}
|
|
}
|