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>
333 lines
12 KiB
Go
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
|
|
}
|