package controllers import ( "context" "encoding/json" "hash/crc32" "io" "net/http/httptest" "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 } // webSession mints a session for THIS test's own user. // // One user id across the file put every test in one rate-limit bucket — six // questions and then 429 for ten seconds — so the suite passed test by test and // failed when run together, which is the worst way round: green locally, red in // CI, and the failure blamed on the model. // // A per-test user is also the truthful shape. The limiter is per person, and // two tests are two people. func webSession(t *testing.T) string { t.Helper() claims := testCaller // Stable across runs and distinct per test, so a failure names the same // user every time. The fakes key on tenant, never on this. claims.Userid = testCaller.Userid + int(crc32.ChecksumIEEE([]byte(t.Name()))%10_000) token, _, err := utils.MintWebToken(claims, 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() // Read exactly as production reads it, so this proves the shipped defaults // work rather than quietly testing a configuration of its own. cfg := config.AssistantFromEnv() 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) } }