Files
compass_teams_nhs/internal/web/hub_api_test.go
Sam Rolfe 06f759c37b Phase 8 follow-up: resolve the child named in the question
The Hub forwards `person` equal to the asker (`PersonOrDefault`), so the spoken
question "what does Harry have on today at school?" arrives with person "sam"
and returned "no data for that person" (found by the Hub agent on AUD-116).

resolveChild now falls back to the question text when the requested person is
not one of this project's children: whole-word, case-insensitive match against
the child names this service already publishes in /capabilities. An explicit
person still wins, two named children still return answer:null, and the
permission check still runs on the resolved child. No model, no free text.

Live end-to-end through the Hub, with no person field:

  POST http://192.168.20.13:8788/ask
  {"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
  200
  {"status":"ok",
   "answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
   "route":{"kind":"project","project":"compass_nhs"}}

Image 0.6.1-phase8; docs/phase-8-hub-notes.md records the fallback and the
end-to-end transcript.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:54:26 +11:00

333 lines
12 KiB
Go

package web
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
const testHubToken = "hub-test-token-0123456789abcdef"
// hubJSON posts a JSON body to a Hub endpoint with the given bearer token.
func hubJSON(t *testing.T, ts *httptest.Server, method, path, token, body string) (*http.Response, []byte) {
t.Helper()
var rdr *bytes.Reader
if body == "" {
rdr = bytes.NewReader(nil)
} else {
rdr = bytes.NewReader([]byte(body))
}
req, err := http.NewRequest(method, ts.URL+path, rdr)
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
raw := readAll(t, resp)
return resp, raw
}
func readAll(t *testing.T, resp *http.Response) []byte {
t.Helper()
var buf bytes.Buffer
if _, err := buf.ReadFrom(resp.Body); err != nil {
t.Fatalf("read body: %v", err)
}
return buf.Bytes()
}
// addHubItem stores one item so /ask has real rows to read.
func addHubItem(t *testing.T, st *store.Store, childID int64, it store.Item) {
t.Helper()
it.ChildID = childID
if it.Source == "" {
it.Source = "compass"
}
if it.ExternalRef == "" {
it.ExternalRef = fmt.Sprintf("hub-test-%d", time.Now().UnixNano())
}
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{it}); err != nil {
t.Fatalf("upsert item %q: %v", it.Title, err)
}
}
func countRows(t *testing.T, st *store.Store, table string) int {
t.Helper()
var n int
if err := st.DB().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
return n
}
// TestHubCapabilitiesAndAsk covers the two read-only endpoints: the routing
// topics the Hub reads, and a correct answer to the phase's question (FR-11,
// §11 Phase 8).
func TestHubCapabilitiesAndAsk(t *testing.T) {
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
loc := mustLoc(t)
ctx := context.Background()
harry, err := st.ChildByName(ctx, "Harry")
if err != nil || harry == nil {
t.Fatalf("harry: %v %v", harry, err)
}
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, loc)
addHubItem(t, st, harry.ID, store.Item{
Type: "event", Subject: "11XMUSA6", Teacher: "JBR",
Title: "1 - 11XMUSA6 - A1 - JBR", DueAt: &today,
})
if _, err := st.RecordScan(ctx, &harry.ID, 5, 1, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
// Capabilities: the Hub reads this to route.
resp, raw := hubJSON(t, ts, http.MethodGet, "/capabilities", testHubToken, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("capabilities status = %d, body = %s", resp.StatusCode, raw)
}
var caps struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
if err := json.Unmarshal(raw, &caps); err != nil {
t.Fatalf("capabilities json: %v (%s)", err, raw)
}
if caps.Project != "compass_nhs" {
t.Errorf("project = %q, want compass_nhs", caps.Project)
}
if len(caps.Topics) == 0 || len(caps.People) == 0 {
t.Errorf("capabilities = %+v, want topics and people", caps)
}
// The phase's question.
resp, raw = hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what does Harry have on today at school?","asker":"sam","person":"harry","source":"esp32"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ask status = %d, body = %s", resp.StatusCode, raw)
}
var ask struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
Expires string `json:"expires"`
}
if err := json.Unmarshal(raw, &ask); err != nil {
t.Fatalf("ask json: %v (%s)", err, raw)
}
if ask.Answer == nil {
t.Fatalf("answer = nil, reason = %q", ask.Reason)
}
if !strings.Contains(*ask.Answer, "1 school event today") {
t.Errorf("answer = %q, want today's event", *ask.Answer)
}
if ask.Expires == "" {
t.Error("expires is empty, want end of day")
}
}
// TestHubAskResolvesTheNamedChild covers the Hub's actual call shape: the Hub
// sends `person` equal to the asker (`PersonOrDefault`), so the child's name is
// only in the question text. The literal phase question must still answer.
func TestHubAskResolvesTheNamedChild(t *testing.T) {
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
loc := mustLoc(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, loc)
addHubItem(t, st, harry.ID, store.Item{
Type: "event", Subject: "11XMUSA6", Teacher: "JBR",
Title: "1 - 11XMUSA6 - A1 - JBR", DueAt: &today,
})
if _, err := st.RecordScan(ctx, &harry.ID, 5, 1, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
resp, raw := hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what does Harry have on today at school?","asker":"sam","person":"sam","source":"console"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ask status = %d, body = %s", resp.StatusCode, raw)
}
var ask struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(raw, &ask); err != nil {
t.Fatalf("ask json: %v (%s)", err, raw)
}
if ask.Answer == nil {
t.Fatalf("answer = nil, reason = %q", ask.Reason)
}
if ask.Data["child"] != "Harry" {
t.Errorf("data.child = %v, want Harry", ask.Data["child"])
}
if !strings.Contains(*ask.Answer, "school event") {
t.Errorf("answer = %q, want the lesson count", *ask.Answer)
}
}
// TestHubAskReadsOnly is the contract's first rule made testable: /ask and
// /capabilities must not write anything (AGENTS.md §10 rule 5).
func TestHubAskReadsOnly(t *testing.T) {
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
now := time.Now()
addHubItem(t, st, harry.ID, store.Item{Type: "task", Title: "SAC 1", DueAt: &now, Body: "Status: Pending"})
if _, err := st.RecordScan(ctx, &harry.ID, 4, 1, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
itemsBefore := countRows(t, st, "items")
scansBefore := countRows(t, st, "scans")
notifyBefore := countRows(t, st, "notifications")
hubJSON(t, ts, http.MethodGet, "/capabilities", testHubToken, "")
hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what does Harry have on today?","asker":"sam","person":"harry"}`)
hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what is overdue for Finn?","asker":"sam","person":"finn"}`)
if after := countRows(t, st, "items"); after != itemsBefore {
t.Errorf("items changed: %d -> %d, /ask must not write", itemsBefore, after)
}
if after := countRows(t, st, "scans"); after != scansBefore {
t.Errorf("scans changed: %d -> %d, /ask must not write", scansBefore, after)
}
if after := countRows(t, st, "notifications"); after != notifyBefore {
t.Errorf("notifications changed: %d -> %d, /ask must not write", notifyBefore, after)
}
}
// TestHubEndpointsNeedToken proves an unsigned request is never trusted, and
// that an unconfigured token turns the API off rather than opening it.
func TestHubEndpointsNeedToken(t *testing.T) {
ts, _ := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
cases := []struct {
method, path, body string
}{
{http.MethodGet, "/capabilities", ""},
{http.MethodPost, "/ask", `{"question":"what does Harry have on today?","asker":"sam"}`},
{http.MethodPost, "/notify", `{"from":"hub","level":"info","message":"hello"}`},
}
for _, tc := range cases {
resp, raw := hubJSON(t, ts, tc.method, tc.path, "", tc.body)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s without a token = %d, want 401 (body %s)", tc.method, tc.path, resp.StatusCode, raw)
}
resp, _ = hubJSON(t, ts, tc.method, tc.path, "wrong-token", tc.body)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s with a bad token = %d, want 401", tc.method, tc.path, resp.StatusCode)
}
}
}
// TestHubDisabledWithoutToken proves an empty SCHOOL_HUB_TOKEN closes the API.
func TestHubDisabledWithoutToken(t *testing.T) {
ts, _ := newTestServerHub(t, testAppriseConfig("", "", ""), "")
resp, raw := hubJSON(t, ts, http.MethodPost, "/ask", "anything",
`{"question":"what does Harry have on today?","asker":"sam"}`)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 (body %s)", resp.StatusCode, raw)
}
var ask struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(raw, &ask); err != nil {
t.Fatalf("json: %v (%s)", err, raw)
}
if ask.Answer != nil || ask.Reason == "" {
t.Errorf("body = %s, want the contract's cannot-answer shape", raw)
}
}
// TestHubNotifyDelivers covers POST /notify: the contract's shape is accepted
// and the message is delivered through Apprise and recorded.
func TestHubNotifyDelivers(t *testing.T) {
apSrv, tags := fakeAppriseServer(t, http.StatusOK)
ts, st := newTestServerHub(t, testAppriseConfig(apSrv.URL, "key1", ""), testHubToken)
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
`{"from":"hub","level":"important","message":"3 school messages need a decision.","person":"Sam"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("notify status = %d, body = %s", resp.StatusCode, raw)
}
if !strings.Contains(string(raw), `"ok":true`) {
t.Errorf("body = %s, want ok true", raw)
}
if len(*tags) != 2 {
t.Errorf("apprise tags = %#v, want one per channel", *tags)
}
rows, err := st.RecentNotifications(context.Background(), 10)
if err != nil || len(rows) != 2 {
t.Fatalf("rows = %d, %v", len(rows), err)
}
for _, n := range rows {
if n.Scope != "hub" || n.Recipient != "Sam" || !n.OK || n.SentAt == nil {
t.Errorf("row = %+v", n)
}
}
}
// TestHubNotifyUnknownPerson refuses to deliver to a person who is not
// configured, rather than guessing an inbox.
func TestHubNotifyUnknownPerson(t *testing.T) {
ts, _ := newTestServerHub(t, testAppriseConfig("http://127.0.0.1:1", "key1", ""), testHubToken)
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
`{"from":"hub","level":"info","message":"hello","person":"nobody"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", resp.StatusCode, raw)
}
}
// TestHubNotifyFailsLoudly proves a delivery failure is reported and recorded,
// never returned as a success (FR-12).
func TestHubNotifyFailsLoudly(t *testing.T) {
apSrv, _ := fakeAppriseServer(t, http.StatusFailedDependency)
ts, st := newTestServerHub(t, testAppriseConfig(apSrv.URL, "key1", ""), testHubToken)
resp, raw := hubJSON(t, ts, http.MethodPost, "/notify", testHubToken,
`{"from":"hub","level":"urgent","message":"blocked","person":"Sam"}`)
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502 (body %s)", resp.StatusCode, raw)
}
rows, _ := st.RecentNotifications(context.Background(), 10)
if len(rows) == 0 {
t.Fatal("no rows recorded")
}
for _, n := range rows {
if n.OK || n.SentAt != nil || n.Error == "" {
t.Errorf("row = %+v, want a recorded failure", n)
}
}
}
func mustLoc(t *testing.T) *time.Location {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
t.Fatalf("location: %v", err)
}
return loc
}