440 lines
15 KiB
Go
440 lines
15 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"nearle/config"
|
|
"nearle/middleware"
|
|
"nearle/models"
|
|
"nearle/services"
|
|
"nearle/services/tools"
|
|
"nearle/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// Nearle Buddy over HTTP, through the guard, as the console reaches it.
|
|
//
|
|
// Everything else tests one layer. The service tests call `Ask` directly with a
|
|
// caller already built; the live tests talk to a real model but never touch a
|
|
// route. Neither would notice the thing most likely to break on a deploy: the
|
|
// seam where a session token becomes a tool caller.
|
|
//
|
|
// That seam has four parts, and a mistake in any one of them produces a console
|
|
// showing an empty panel and a server logging nothing —
|
|
//
|
|
// the route sits under /v1/web, so WebAuth runs at all
|
|
// WebAuth verifies the token and parks the claims
|
|
// callerFrom reads those claims rather than the request body
|
|
// the answer comes back inside `details`, where the console's client looks
|
|
//
|
|
// No database: every tool is handed a fake, so this runs in CI beside the unit
|
|
// tests. The ones that need a model skip without a key.
|
|
|
|
const testSecret = "a-test-signing-secret-of-ample-length"
|
|
|
|
var testCaller = utils.WebClaims{Userid: 904, Tenantid: 1147}
|
|
|
|
// ── the shop these tests run against ────────────────────────────────────────
|
|
|
|
type fakeShop struct {
|
|
deliveries []models.Deliveryinfo
|
|
requests []models.StockRequest
|
|
// approved records what reached the write half, so the approval test can
|
|
// assert the change happened rather than that it was described.
|
|
approved []string
|
|
}
|
|
|
|
func (f *fakeShop) GetDeliveries(models.DeliveryQuery) []models.Deliveryinfo { return f.deliveries }
|
|
|
|
func (f *fakeShop) GetStockRequests(tenantID, _ int, status, _ string, _, _ int) ([]models.StockRequest, error) {
|
|
// Honours the tenant on purpose. A fake that returned rows to anybody would
|
|
// let an ownership bug pass this test.
|
|
if tenantID != testCaller.Tenantid || !strings.EqualFold(status, "Pending") {
|
|
return nil, nil
|
|
}
|
|
return f.requests, nil
|
|
}
|
|
|
|
func (f *fakeShop) UpdateStockRequest(requestID int, status string) error {
|
|
f.approved = append(f.approved, status+" #"+strconv.Itoa(requestID))
|
|
for i := range f.requests {
|
|
if f.requests[i].Requestid == requestID {
|
|
// Drops out of the pending list, as the real update does. Without
|
|
// this, approving the same card twice would succeed twice.
|
|
f.requests = append(f.requests[:i], f.requests[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The tools these tests do not exercise still have to exist, because the
|
|
// shipped agents name them and LoadAgents refuses an agent naming a tool that
|
|
// is absent. An empty answer is the honest fake: a shop with nothing to report.
|
|
func (f *fakeShop) GetLocationOrderSummary(int) ([]models.Ordersummarylocation, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeShop) GetProductStocks(string, string) ([]models.Productstocks, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeShop) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeShop) GetRevenueSummary(int, int, string, string) (*models.TenantRevenueSummary, error) {
|
|
return &models.TenantRevenueSummary{}, nil
|
|
}
|
|
|
|
func (f *fakeShop) SalesSummary(models.PosSalesFilter) (*models.PosSalesSummary, error) {
|
|
return &models.PosSalesSummary{}, nil
|
|
}
|
|
|
|
func newShop() *fakeShop {
|
|
now := time.Now()
|
|
stamp := func(minutesAgo int) string {
|
|
return now.Add(-time.Duration(minutesAgo) * time.Minute).Format("2006-01-02 15:04:05")
|
|
}
|
|
return &fakeShop{
|
|
deliveries: []models.Deliveryinfo{
|
|
{Deliveryid: 4412, Orderid: "ORD-4412", Orderstatus: "pending", Assigntime: stamp(41),
|
|
Ridername: "Varun", Locationname: "R Mart"},
|
|
{Deliveryid: 4421, Orderid: "ORD-4421", Orderstatus: "delivered", Assigntime: stamp(200)},
|
|
},
|
|
requests: []models.StockRequest{{
|
|
Requestid: 41, Productname: "Sona Masoori rice 25kg", Qty: 12,
|
|
Locationname: "R Mart", Status: "Pending", Created: now.Add(-36 * time.Hour),
|
|
}},
|
|
}
|
|
}
|
|
|
|
// ── the server, wired the way production wires it ───────────────────────────
|
|
|
|
func buildApp(t *testing.T, chat utils.Chat) (*fiber.App, *fakeShop) {
|
|
t.Helper()
|
|
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
|
|
|
shop := newShop()
|
|
|
|
corpus, err := tools.LoadHelp()
|
|
if err != nil {
|
|
t.Fatalf("help corpus: %v", err)
|
|
}
|
|
|
|
registry := tools.New(tools.DiscardAudit{})
|
|
for _, tool := range []tools.Tool{
|
|
tools.StuckOrders(shop, nil),
|
|
tools.DeliveryProgress(shop),
|
|
tools.BranchPerformance(shop),
|
|
tools.PendingApprovals(shop, nil),
|
|
tools.LowStock(shop),
|
|
tools.TillsNotSyncing(shop),
|
|
tools.SalesByChannel(shop, shop, nil),
|
|
tools.Help(corpus),
|
|
tools.ApproveStockRequest(shop, shop),
|
|
} {
|
|
if err := registry.Register(tool); err != nil {
|
|
t.Fatalf("registering %s: %v", tool.Name, err)
|
|
}
|
|
}
|
|
|
|
// The shipped agent definitions, not a hand-built stand-in. A typo in
|
|
// agents/inventory.yaml should fail here rather than on deploy.
|
|
agents, err := services.LoadAgents("", registry.Has)
|
|
if err != nil {
|
|
t.Fatalf("agents: %v", err)
|
|
}
|
|
|
|
assistant := services.NewAssistantService(registry, chat, agents)
|
|
// Mirrors facade.NewFacade: with no model, the reason the config gives is
|
|
// threaded through to the service so /status can name the missing variable.
|
|
// Built the same way here, or this would assert a string production never
|
|
// produces.
|
|
if setter, ok := assistant.(interface{ SetUnavailableReason(string) }); ok && chat == nil {
|
|
setter.SetUnavailableReason(config.AssistantConfig{}.Why())
|
|
}
|
|
|
|
controller := NewAssistantController(assistant)
|
|
|
|
app := fiber.New()
|
|
// nil is the branch-ownership checker, consulted only when a request names
|
|
// a branch. The assistant's body names none — that is the design — so
|
|
// nothing here can reach it.
|
|
app.Use(middleware.WebAuth(nil))
|
|
web := app.Group("/live/api/v1/web")
|
|
web.Get("/assistant/status", controller.Status)
|
|
web.Post("/assistant/ask", controller.Ask)
|
|
web.Post("/assistant/approve", controller.Approve)
|
|
|
|
return app, shop
|
|
}
|
|
|
|
func webSession(t *testing.T) string {
|
|
t.Helper()
|
|
token, _, err := utils.MintWebToken(testCaller, time.Now())
|
|
if err != nil {
|
|
t.Fatalf("minting a session: %v", err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
// envelope is the shape every Fiesta handler answers with, and the shape the
|
|
// console's client unwraps. Asserting on it rather than on the Go struct is the
|
|
// point: a controller returning the answer at the top level would pass a
|
|
// service-level test and hand the console `undefined`.
|
|
type envelope struct {
|
|
Code int `json:"code"`
|
|
Status bool `json:"status"`
|
|
Message string `json:"message"`
|
|
Details services.AssistantAnswer `json:"details"`
|
|
}
|
|
|
|
const (
|
|
statusPath = "/live/api/v1/web/assistant/status"
|
|
askPath = "/live/api/v1/web/assistant/ask"
|
|
approvePath = "/live/api/v1/web/assistant/approve"
|
|
)
|
|
|
|
func post(t *testing.T, app *fiber.App, path, token, body string) (int, envelope, string) {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
resp, err := app.Test(req, -1)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", path, err)
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
var out envelope
|
|
_ = json.Unmarshal(raw, &out)
|
|
return resp.StatusCode, out, string(raw)
|
|
}
|
|
|
|
func quote(s string) string {
|
|
out, _ := json.Marshal(s)
|
|
return string(out)
|
|
}
|
|
|
|
// ── the guard ───────────────────────────────────────────────────────────────
|
|
|
|
func TestAnUntokenedQuestionIsRefusedOverHTTP(t *testing.T) {
|
|
// WEB_AUTH_REQUIRED defaults on now, so the middleware turns this away
|
|
// before the controller sees it. Either refusal is correct; what must never
|
|
// happen is an answer.
|
|
app, _ := buildApp(t, nil)
|
|
|
|
status, _, body := post(t, app, askPath, "", `{"agent":"orders","question":"what is stuck?"}`)
|
|
|
|
if status == fiber.StatusOK {
|
|
t.Fatalf("an untokened question was answered: %s", body)
|
|
}
|
|
if status != fiber.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d: %s", status, body)
|
|
}
|
|
}
|
|
|
|
func TestATamperedTokenIsRefusedOverHTTP(t *testing.T) {
|
|
app, _ := buildApp(t, nil)
|
|
|
|
// Three characters at the end — the edit somebody would actually attempt.
|
|
broken := webSession(t)
|
|
broken = broken[:len(broken)-3] + "AAA"
|
|
|
|
status, _, body := post(t, app, askPath, broken, `{"question":"what is stuck?"}`)
|
|
if status != fiber.StatusUnauthorized {
|
|
t.Fatalf("a tampered session was not refused: %d %s", status, body)
|
|
}
|
|
}
|
|
|
|
func TestStatusNamesTheMissingVariable(t *testing.T) {
|
|
// Why the field exists: "available: false" alone is the same answer for "we
|
|
// have not switched it on" and "somebody misspelled a variable", and those
|
|
// need different actions from whoever is looking.
|
|
app, _ := buildApp(t, nil)
|
|
|
|
req := httptest.NewRequest("GET", statusPath, nil)
|
|
req.Header.Set("Authorization", "Bearer "+webSession(t))
|
|
|
|
resp, err := app.Test(req, -1)
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
var out struct {
|
|
Details struct {
|
|
Available bool `json:"available"`
|
|
Reason string `json:"reason"`
|
|
} `json:"details"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
t.Fatalf("status is not the envelope the console unwraps: %s", raw)
|
|
}
|
|
if out.Details.Available {
|
|
t.Fatal("reported available with no model configured")
|
|
}
|
|
if out.Details.Reason == "" {
|
|
t.Fatalf("said no without saying why: %s", raw)
|
|
}
|
|
// Names the variable, not merely the symptom. "no assistant model is
|
|
// configured" is what the service says on its own, and it is the answer
|
|
// that left this switched off without anybody being able to tell which
|
|
// variable was wrong.
|
|
if !strings.Contains(out.Details.Reason, "ASSISTANT_") {
|
|
t.Fatalf("the reason names no variable to go and set: %q", out.Details.Reason)
|
|
}
|
|
t.Logf("reason: %s", out.Details.Reason)
|
|
}
|
|
|
|
// ── the live path ───────────────────────────────────────────────────────────
|
|
|
|
func liveHTTPChat(t *testing.T) utils.Chat {
|
|
t.Helper()
|
|
|
|
// Defaults exactly as production does, so this proves three variables are
|
|
// enough rather than working around the question.
|
|
provider := strings.ToLower(strings.TrimSpace(os.Getenv("ASSISTANT_PROVIDER")))
|
|
model := strings.TrimSpace(os.Getenv("ASSISTANT_MODEL"))
|
|
if provider == "" && model != "" {
|
|
provider = "openai"
|
|
}
|
|
|
|
cfg := config.AssistantConfig{
|
|
Provider: provider,
|
|
BaseURL: os.Getenv("ASSISTANT_BASE_URL"),
|
|
APIKey: os.Getenv("ASSISTANT_API_KEY"),
|
|
Balanced: model,
|
|
}
|
|
if !cfg.Enabled() {
|
|
t.Skipf("no model configured: %s", cfg.Why())
|
|
}
|
|
|
|
chat, err := utils.NewChat(cfg)
|
|
if err != nil || chat == nil {
|
|
t.Skipf("gateway not built: %v", err)
|
|
}
|
|
return chat
|
|
}
|
|
|
|
func TestLiveAQuestionAnswersThroughTheWholeStack(t *testing.T) {
|
|
app, _ := buildApp(t, liveHTTPChat(t))
|
|
|
|
status, out, body := post(t, app, askPath, webSession(t),
|
|
`{"agent":"orders","question":"Which orders are stuck?"}`)
|
|
|
|
if status != fiber.StatusOK {
|
|
t.Fatalf("HTTP %d: %s", status, body)
|
|
}
|
|
if !out.Status {
|
|
t.Fatalf("envelope says failure: %s", out.Message)
|
|
}
|
|
// Inside `details`, where the console's client reads. A correct answer at
|
|
// the top level is still a broken console.
|
|
if strings.TrimSpace(out.Details.Reply) == "" {
|
|
t.Fatalf("no reply in details: %s", body)
|
|
}
|
|
if len(out.Details.Used) == 0 {
|
|
t.Fatalf("answered without running a tool — it invented it: %s", out.Details.Reply)
|
|
}
|
|
t.Logf("used: %+v", out.Details.Used)
|
|
t.Logf("reply: %s", out.Details.Reply)
|
|
}
|
|
|
|
func TestLiveTheAnswerIsScopedToTheSessionsTenant(t *testing.T) {
|
|
// The claim the whole design rests on. The request body carries no tenant,
|
|
// so rows can only be reached through the token — and a session whose shop
|
|
// has nothing pending must not be handed a list.
|
|
app, shop := buildApp(t, liveHTTPChat(t))
|
|
shop.requests = nil
|
|
|
|
status, out, body := post(t, app, askPath, webSession(t),
|
|
`{"agent":"inventory","question":"What stock requests are waiting for approval?"}`)
|
|
|
|
if status != fiber.StatusOK {
|
|
t.Fatalf("HTTP %d: %s", status, body)
|
|
}
|
|
if strings.Contains(out.Details.Reply, "Sona Masoori") {
|
|
t.Fatalf("named a row this session cannot see: %s", out.Details.Reply)
|
|
}
|
|
t.Logf("reply: %s", out.Details.Reply)
|
|
}
|
|
|
|
// ── the approval card, end to end ───────────────────────────────────────────
|
|
|
|
func TestLiveAnApprovalCardRoundTripsAndWrites(t *testing.T) {
|
|
// The one path that has never run whole. The model proposes, the card comes
|
|
// back signed, the console sends it in unchanged, and only then does
|
|
// anything change. Each half has unit tests; this is the join.
|
|
app, shop := buildApp(t, liveHTTPChat(t))
|
|
token := webSession(t)
|
|
|
|
status, out, body := post(t, app, askPath, token,
|
|
`{"agent":"inventory","question":"Approve stock request 41."}`)
|
|
if status != fiber.StatusOK {
|
|
t.Fatalf("asking: HTTP %d: %s", status, body)
|
|
}
|
|
if out.Details.Awaiting == nil {
|
|
t.Fatalf("no approval card came back — nothing to press: %s", out.Details.Reply)
|
|
}
|
|
card := out.Details.Awaiting.Card
|
|
t.Logf("card: %s", out.Details.Awaiting.Summary)
|
|
|
|
// Nothing may have happened yet. A write at proposal time is the failure
|
|
// the whole two-step exists to prevent.
|
|
if len(shop.approved) != 0 {
|
|
t.Fatalf("the change was made before anybody agreed to it: %v", shop.approved)
|
|
}
|
|
|
|
status, done, body := post(t, app, approvePath, token,
|
|
`{"agent":"inventory","card":`+quote(card)+`}`)
|
|
if status != fiber.StatusOK {
|
|
t.Fatalf("approving: HTTP %d: %s", status, body)
|
|
}
|
|
if len(shop.approved) != 1 || shop.approved[0] != "Approved #41" {
|
|
t.Fatalf("the write did not reach the service: %v", shop.approved)
|
|
}
|
|
t.Logf("after approval: %s", done.Details.Reply)
|
|
|
|
// Pressing twice must not approve twice. The card still verifies; the row
|
|
// is no longer pending, and the re-check at execute time is what notices.
|
|
status, _, _ = post(t, app, approvePath, token,
|
|
`{"agent":"inventory","card":`+quote(card)+`}`)
|
|
if status == fiber.StatusOK {
|
|
t.Fatal("the same card approved the same request twice")
|
|
}
|
|
if len(shop.approved) != 1 {
|
|
t.Fatalf("a second write got through: %v", shop.approved)
|
|
}
|
|
}
|
|
|
|
func TestAForgedCardIsRefused(t *testing.T) {
|
|
// No model needed: a card that does not verify must be refused before
|
|
// anything reads what it claims.
|
|
app, shop := buildApp(t, nil)
|
|
|
|
status, _, body := post(t, app, approvePath, webSession(t),
|
|
`{"agent":"inventory","card":"w1.bm90LWEtcmVhbC1jYXJk.c2lnbmF0dXJl"}`)
|
|
|
|
if status == fiber.StatusOK {
|
|
t.Fatalf("a forged card was accepted: %s", body)
|
|
}
|
|
if len(shop.approved) != 0 {
|
|
t.Fatalf("a forged card changed something: %v", shop.approved)
|
|
}
|
|
}
|