381 lines
14 KiB
Go
381 lines
14 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"nearle/models"
|
|
)
|
|
|
|
const cardSecret = "a-test-signing-key-long-enough"
|
|
|
|
// writingApprovals records every write, so a test can assert that nothing
|
|
// happened as easily as that something did.
|
|
type writingApprovals struct {
|
|
fakeApprovals
|
|
wrote []int
|
|
status string
|
|
err error
|
|
}
|
|
|
|
func (w *writingApprovals) UpdateStockRequest(requestID int, status string) error {
|
|
if w.err != nil {
|
|
return w.err
|
|
}
|
|
w.wrote = append(w.wrote, requestID)
|
|
w.status = status
|
|
return nil
|
|
}
|
|
|
|
func pendingRequest(id, qty int, product, branch string) models.StockRequest {
|
|
return models.StockRequest{
|
|
Requestid: id, Qty: qty, Productname: product, Locationname: branch,
|
|
Status: "Pending", Created: time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
func approvalSetup(t *testing.T, rows ...models.StockRequest) (*Registry, *writingApprovals, Agent) {
|
|
t.Helper()
|
|
t.Setenv("POS_TOKEN_SECRET", cardSecret)
|
|
|
|
store := &writingApprovals{}
|
|
store.rows = rows
|
|
r := New(nil)
|
|
if err := r.Register(ApproveStockRequest(store, store)); err != nil {
|
|
t.Fatalf("registering: %v", err)
|
|
}
|
|
return r, store, Agent{Name: "inventory", Tools: []string{"approve_stock_request"}}
|
|
}
|
|
|
|
var owner = Caller{Userid: 904, Tenantid: 1147}
|
|
|
|
// propose runs the model's half and returns the card.
|
|
func propose(t *testing.T, r *Registry, agent Agent, id int, caller Caller) (Proposal, error) {
|
|
t.Helper()
|
|
result, err := r.Call(context.Background(), agent, "approve_stock_request",
|
|
map[string]any{"requestid": id}, caller)
|
|
if err != nil {
|
|
return Proposal{}, err
|
|
}
|
|
proposal, ok := result.Rows.(Proposal)
|
|
if !ok {
|
|
t.Fatalf("a write returned %T, not a proposal", result.Rows)
|
|
}
|
|
return proposal, nil
|
|
}
|
|
|
|
/* ── Nothing auto-executes ─────────────────────────────────────────────── */
|
|
|
|
func TestAskingForAWriteChangesNothing(t *testing.T) {
|
|
// The whole phase in one test. The model asked; the database did not move.
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
|
|
proposal, err := propose(t, r, agent, 41, owner)
|
|
if err != nil {
|
|
t.Fatalf("proposing: %v", err)
|
|
}
|
|
if len(store.wrote) != 0 {
|
|
t.Fatalf("a write happened on the model's say so: %v", store.wrote)
|
|
}
|
|
if proposal.Card == "" {
|
|
t.Fatal("no card came back, so nothing can be approved")
|
|
}
|
|
}
|
|
|
|
func TestTheModelIsToldItHasNotDoneTheThing(t *testing.T) {
|
|
// Without this it reports the action in the past tense, and the person
|
|
// believes it.
|
|
r, _, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
|
|
result, err := r.Call(context.Background(), agent, "approve_stock_request",
|
|
map[string]any{"requestid": 41}, owner)
|
|
if err != nil {
|
|
t.Fatalf("proposing: %v", err)
|
|
}
|
|
note := strings.ToLower(result.Note)
|
|
if !strings.Contains(note, "not been done") || !strings.Contains(note, "do not say it is done") {
|
|
t.Fatalf("the model was not told to hold off: %q", result.Note)
|
|
}
|
|
}
|
|
|
|
func TestAWriteToolCannotBeBuiltWithAPlainHandler(t *testing.T) {
|
|
// The structural half: there is no shape of Tool a caller can construct
|
|
// that writes when the model asks for it.
|
|
r := New(nil)
|
|
err := r.Register(Tool{
|
|
Name: "sneaky", Description: strings.Repeat("a write pretending to be ordinary ", 3),
|
|
Scope: ScopeWrite,
|
|
Handler: func(context.Context, Request) (Result, error) {
|
|
t.Fatal("a hand-built write tool ran")
|
|
return Result{}, nil
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("a write tool was registered without a propose/execute pair")
|
|
}
|
|
}
|
|
|
|
/* ── Approving ─────────────────────────────────────────────────────────── */
|
|
|
|
func TestAnApprovedCardPerformsTheWriteExactlyOnce(t *testing.T) {
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
|
|
result, err := r.Approve(context.Background(), agent, proposal.Card, owner)
|
|
if err != nil {
|
|
t.Fatalf("approving: %v", err)
|
|
}
|
|
if len(store.wrote) != 1 || store.wrote[0] != 41 {
|
|
t.Fatalf("the write did not happen as expected: %v", store.wrote)
|
|
}
|
|
if store.status != "Approved" {
|
|
t.Fatalf("wrote status %q — the column expects the capitalised word", store.status)
|
|
}
|
|
if result.Count != 1 {
|
|
t.Fatalf("the result does not describe the change: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestAnUnapprovedCardIsTheOnlyWayInAndAForgedOneFails(t *testing.T) {
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
|
|
for _, forged := range []string{
|
|
"", "c1.", "c1.rubbish.signature", "not-a-card",
|
|
"c1.eyJ0IjoiYXBwcm92ZV9zdG9ja19yZXF1ZXN0In0.wrongsignature",
|
|
} {
|
|
if _, err := r.Approve(context.Background(), agent, forged, owner); err == nil {
|
|
t.Fatalf("a forged card was accepted: %q", forged)
|
|
}
|
|
}
|
|
if len(store.wrote) != 0 {
|
|
t.Fatalf("a forged card wrote something: %v", store.wrote)
|
|
}
|
|
}
|
|
|
|
func TestACardIsNotTransferable(t *testing.T) {
|
|
// Without this, one person's approval could be replayed by another —
|
|
// including somebody in a different shop.
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
|
|
someoneElse := Caller{Userid: 905, Tenantid: 1147}
|
|
if _, err := r.Approve(context.Background(), agent, proposal.Card, someoneElse); !errors.Is(err, ErrNotYourCard) {
|
|
t.Fatalf("another user approved somebody else's card: %v", err)
|
|
}
|
|
|
|
anotherShop := Caller{Userid: 904, Tenantid: 916}
|
|
if _, err := r.Approve(context.Background(), agent, proposal.Card, anotherShop); !errors.Is(err, ErrNotYourCard) {
|
|
t.Fatalf("another shop approved this card: %v", err)
|
|
}
|
|
|
|
if len(store.wrote) != 0 {
|
|
t.Fatalf("a transferred card wrote something: %v", store.wrote)
|
|
}
|
|
}
|
|
|
|
func TestApprovalDoesNotCarryForward(t *testing.T) {
|
|
// Approving one request is not consent for the next. A second card has to
|
|
// be resolved and approved on its own.
|
|
r, store, agent := approvalSetup(t,
|
|
pendingRequest(41, 12, "Rice", "R Mart"),
|
|
pendingRequest(42, 3, "Oil", "R Mart"))
|
|
|
|
first, _ := propose(t, r, agent, 41, owner)
|
|
if _, err := r.Approve(context.Background(), agent, first.Card, owner); err != nil {
|
|
t.Fatalf("approving: %v", err)
|
|
}
|
|
if len(store.wrote) != 1 || store.wrote[0] != 41 {
|
|
t.Fatalf("approving one request touched another: %v", store.wrote)
|
|
}
|
|
}
|
|
|
|
/* ── Re-validated at execution ─────────────────────────────────────────── */
|
|
|
|
func TestACardApprovedTwiceDoesNotWriteTwice(t *testing.T) {
|
|
// A signed card carries no nonce. Replay is refused where it belongs —
|
|
// against the live database, which no longer has the request pending.
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
|
|
if _, err := r.Approve(context.Background(), agent, proposal.Card, owner); err != nil {
|
|
t.Fatalf("first approval: %v", err)
|
|
}
|
|
// The request is no longer pending, exactly as the database would report.
|
|
store.rows = nil
|
|
|
|
if _, err := r.Approve(context.Background(), agent, proposal.Card, owner); err == nil {
|
|
t.Fatal("the same card wrote a second time")
|
|
}
|
|
if len(store.wrote) != 1 {
|
|
t.Fatalf("the write happened %d times", len(store.wrote))
|
|
}
|
|
}
|
|
|
|
func TestACardExpires(t *testing.T) {
|
|
// A card resolved against a shop half an hour ago is a card about a shop
|
|
// that has moved on.
|
|
r, _, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
|
|
r.now = func() time.Time { return time.Now().Add(2 * CardExpiryForTests) }
|
|
if _, err := r.Approve(context.Background(), agent, proposal.Card, owner); err == nil {
|
|
t.Fatal("an expired card was approved")
|
|
}
|
|
}
|
|
|
|
func TestAFailedWriteIsReportedNotSwallowed(t *testing.T) {
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
store.err = errors.New("the database is down")
|
|
|
|
if _, err := r.Approve(context.Background(), agent, proposal.Card, owner); err == nil {
|
|
t.Fatal("a failed write reported success")
|
|
}
|
|
}
|
|
|
|
/* ── Ownership is re-derived, never compared ───────────────────────────── */
|
|
|
|
func TestARequestFromAnotherShopIsNotFound(t *testing.T) {
|
|
// The lookup is scoped to the caller's tenant, so another merchant's id is
|
|
// simply absent. "Not found" and "not yours" are deliberately the same
|
|
// message — distinguishing them would make this a way to ask whether an id
|
|
// exists somewhere else on the platform.
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
|
|
if _, err := propose(t, r, agent, 999, owner); err == nil {
|
|
t.Fatal("a request that is not this shop's was resolved")
|
|
}
|
|
if len(store.wrote) != 0 {
|
|
t.Fatalf("something was written: %v", store.wrote)
|
|
}
|
|
}
|
|
|
|
func TestTheModelCannotNameAShop(t *testing.T) {
|
|
tool := ApproveStockRequest(&writingApprovals{}, &writingApprovals{})
|
|
for _, field := range tool.Schema.Fields {
|
|
if scopingArguments[strings.ToLower(field.Name)] {
|
|
t.Fatalf("the write offers %q for the model to set", field.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── What the person sees ──────────────────────────────────────────────── */
|
|
|
|
func TestTheCardShowsWhatWasResolvedNotWhatWasAsked(t *testing.T) {
|
|
// A person approving "approve this request" must see which request, from
|
|
// which branch, for what — the sentence and the action are produced by
|
|
// different things and only one of them is checkable.
|
|
r, _, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
|
|
if !strings.Contains(proposal.Summary, "Rice") || !strings.Contains(proposal.Summary, "R Mart") {
|
|
t.Fatalf("the summary names nothing checkable: %q", proposal.Summary)
|
|
}
|
|
|
|
labels := map[string]string{}
|
|
for _, detail := range proposal.Details {
|
|
labels[detail.Label] = detail.Value
|
|
}
|
|
if labels["Request"] != "#41" {
|
|
t.Fatalf("the card does not name the request by id: %+v", proposal.Details)
|
|
}
|
|
for _, want := range []string{"Product", "Quantity", "Branch"} {
|
|
if labels[want] == "" {
|
|
t.Fatalf("the card is missing %q: %+v", proposal.Details, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnusualQuantityIsFlaggedAndAnOrdinaryOneIsNot(t *testing.T) {
|
|
// A warning on every card is a warning on none.
|
|
r, _, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
ordinary, _ := propose(t, r, agent, 41, owner)
|
|
if ordinary.Warning != "" {
|
|
t.Fatalf("an ordinary request was flagged: %q", ordinary.Warning)
|
|
}
|
|
|
|
r2, _, agent2 := approvalSetup(t, pendingRequest(42, 9000, "Rice", "R Mart"))
|
|
large, _ := propose(t, r2, agent2, 42, owner)
|
|
if large.Warning == "" {
|
|
t.Fatal("nine thousand units passed without comment")
|
|
}
|
|
}
|
|
|
|
/* ── The audit trail ───────────────────────────────────────────────────── */
|
|
|
|
func TestAnApprovalIsRecordedBeforeTheWriteLeaves(t *testing.T) {
|
|
// A crash mid-write has to leave a trace that it was attempted. A row
|
|
// written only on success is missing exactly when it is needed.
|
|
t.Setenv("POS_TOKEN_SECRET", cardSecret)
|
|
audit := &CollectAudit{}
|
|
store := &writingApprovals{}
|
|
store.rows = []models.StockRequest{pendingRequest(41, 12, "Rice", "R Mart")}
|
|
store.err = errors.New("the database died mid-write")
|
|
|
|
r := New(audit)
|
|
_ = r.Register(ApproveStockRequest(store, store))
|
|
agent := Agent{Name: "inventory", Tools: []string{"approve_stock_request"}}
|
|
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
_, _ = r.Approve(context.Background(), agent, proposal.Card, owner)
|
|
|
|
var sawApproved, sawFailed bool
|
|
for _, entry := range audit.Entries {
|
|
if entry.Outcome == "approved" {
|
|
sawApproved = true
|
|
}
|
|
if entry.Outcome == OutcomeFailed {
|
|
sawFailed = true
|
|
}
|
|
}
|
|
if !sawApproved {
|
|
t.Fatal("a write that died left no record that it was attempted")
|
|
}
|
|
if !sawFailed {
|
|
t.Fatal("the failure itself was not recorded")
|
|
}
|
|
}
|
|
|
|
func TestARejectedCardLeavesNoSideEffect(t *testing.T) {
|
|
// Rejection is the console never calling Approve. Nothing to undo, nothing
|
|
// half-written — and the proposal itself is still in the trail.
|
|
t.Setenv("POS_TOKEN_SECRET", cardSecret)
|
|
audit := &CollectAudit{}
|
|
store := &writingApprovals{}
|
|
store.rows = []models.StockRequest{pendingRequest(41, 12, "Rice", "R Mart")}
|
|
|
|
r := New(audit)
|
|
_ = r.Register(ApproveStockRequest(store, store))
|
|
agent := Agent{Name: "inventory", Tools: []string{"approve_stock_request"}}
|
|
|
|
if _, err := propose(t, r, agent, 41, owner); err != nil {
|
|
t.Fatalf("proposing: %v", err)
|
|
}
|
|
|
|
if len(store.wrote) != 0 {
|
|
t.Fatalf("an unapproved proposal wrote something: %v", store.wrote)
|
|
}
|
|
last, _ := audit.Last()
|
|
if last.Outcome != "proposed" {
|
|
t.Fatalf("the proposal is not in the trail: %+v", last)
|
|
}
|
|
}
|
|
|
|
/* ── The allow-list still applies at approval ──────────────────────────── */
|
|
|
|
func TestACardCannotOutliveAnAgentLosingTheTool(t *testing.T) {
|
|
r, store, agent := approvalSetup(t, pendingRequest(41, 12, "Rice", "R Mart"))
|
|
proposal, _ := propose(t, r, agent, 41, owner)
|
|
|
|
narrowed := Agent{Name: "inventory", Tools: []string{"low_stock"}}
|
|
if _, err := r.Approve(context.Background(), narrowed, proposal.Card, owner); !errors.Is(err, ErrNotAllowed) {
|
|
t.Fatalf("a card was a way around the allow-list: %v", err)
|
|
}
|
|
if len(store.wrote) != 0 {
|
|
t.Fatalf("something was written: %v", store.wrote)
|
|
}
|
|
}
|