Files
compass_teams_nhs/internal/web/hub_api_test.go
Sam Rolfe 9cbfa7db9b Phase 8: Hub API (/capabilities, read-only /ask, /notify)
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:

  GET  /capabilities  the routing topics the Hub reads, project "compass_nhs"
  POST /ask           one short, read-only answer about a child's school work
  POST /notify        accept the contract's notification shape, deliver via Apprise

All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".

/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.

Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.

/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.

Live proof on .13 (deploy/verify-phase8.sh):
  /health        200, ok true
  /capabilities  200, project compass_nhs; unsigned 401
  /ask           200, the sentence above, duration 1ms
  /notify        400 on a bad level (auth proven, nothing sent), then 200 with
                 a real delivery to Sam on email and WhatsApp

The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.

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

291 lines
9.9 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")
}
}
// 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
}