Files
backend_fiesta/controllers/assistantHTTP_test.go
2026-09-24 12:36:33 +05:30

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)
}
}