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>
This commit is contained in:
244
internal/hub/hub_test.go
Normal file
244
internal/hub/hub_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
// fakeReader is the read-only Reader backed by fixed data. It has no write
|
||||
// method, which is how the "read only" contract is enforced on /ask.
|
||||
type fakeReader struct {
|
||||
children []store.Child
|
||||
items map[int64][]store.Item
|
||||
scans map[int64]store.ScanStatus
|
||||
}
|
||||
|
||||
func (f *fakeReader) ListChildren(context.Context) ([]store.Child, error) {
|
||||
return f.children, nil
|
||||
}
|
||||
|
||||
func (f *fakeReader) ItemsForChild(_ context.Context, childID int64) ([]store.Item, error) {
|
||||
return f.items[childID], nil
|
||||
}
|
||||
|
||||
func (f *fakeReader) ScanSummaryForChild(_ context.Context, childID int64) (store.ScanStatus, error) {
|
||||
return f.scans[childID], nil
|
||||
}
|
||||
|
||||
// testNow is a fixed Wednesday afternoon in the family timezone.
|
||||
var testNow = time.Date(2026, 10, 15, 12, 0, 0, 0, mustSydney())
|
||||
|
||||
func mustSydney() *time.Location {
|
||||
loc, err := time.LoadLocation("Australia/Sydney")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
func at(y int, mo time.Month, d, h, mi int) *time.Time {
|
||||
t := time.Date(y, mo, d, h, mi, 0, 0, mustSydney())
|
||||
return &t
|
||||
}
|
||||
|
||||
func okScan(t time.Time) store.ScanStatus {
|
||||
return store.ScanStatus{EverRun: true, Total: 3, Last: &store.Scan{ID: 3, StartedAt: t, OK: true}}
|
||||
}
|
||||
|
||||
func testAnswerer(r Reader) *Answerer {
|
||||
a := NewAnswerer(r, mustSydney())
|
||||
a.Now = func() time.Time { return testNow }
|
||||
return a
|
||||
}
|
||||
|
||||
// harryData is one realistic day: eight lessons, one task due today, two
|
||||
// overdue SACs, one task this week and one notice.
|
||||
func harryData() *fakeReader {
|
||||
return &fakeReader{
|
||||
children: []store.Child{{ID: 1, Name: "Harry"}, {ID: 2, Name: "Finn"}},
|
||||
items: map[int64][]store.Item{1: {
|
||||
{ID: 1, ChildID: 1, Type: "event", Title: "8:55: 11XMUSA6 - A1 - JBR", DueAt: at(2026, 10, 15, 8, 55)},
|
||||
{ID: 2, ChildID: 1, Type: "event", Title: "9:50: 11AACPA4 - C1 - BDR", DueAt: at(2026, 10, 15, 9, 50)},
|
||||
{ID: 3, ChildID: 1, Type: "event", Title: "11:00: 11SBIOA1 - M4 - AFR", DueAt: at(2026, 10, 15, 11, 0)},
|
||||
{ID: 4, ChildID: 1, Type: "event", Title: "14:00: 11EENGB3 - H3 - CHA", DueAt: at(2026, 10, 15, 14, 0)},
|
||||
{ID: 5, ChildID: 1, Type: "event", Title: "18:00: Senior Music Concert - HALL", DueAt: at(2026, 10, 15, 18, 0)},
|
||||
{ID: 6, ChildID: 1, Type: "task", Subject: "11EENG ENGLISH", Title: "SAC 3: Responding to Texts",
|
||||
Body: "Status: Pending\nDue: 2026-10-15 16:00", DueAt: at(2026, 10, 15, 16, 0)},
|
||||
{ID: 7, ChildID: 1, Type: "task", Subject: "11HLEG LEGAL STUDIES", Title: "SAC 6: Human Rights",
|
||||
Body: "Status: Overdue", DueAt: at(2026, 10, 9, 16, 0)},
|
||||
{ID: 8, ChildID: 1, Type: "task", Subject: "11XMUS MUSIC", Title: "SAC 1: Performing",
|
||||
Body: "Status: Overdue", DueAt: at(2026, 10, 5, 16, 0)},
|
||||
{ID: 9, ChildID: 1, Type: "task", Subject: "11MGMA MATHS", Title: "SAC 4: Networks",
|
||||
Body: "Status: Pending", DueAt: at(2026, 10, 19, 16, 0)},
|
||||
{ID: 10, ChildID: 1, Type: "message", Title: "Health Information", DueAt: at(2026, 10, 13, 9, 0)},
|
||||
}},
|
||||
scans: map[int64]store.ScanStatus{1: okScan(testNow.Add(-4 * time.Hour))},
|
||||
}
|
||||
}
|
||||
|
||||
func answerOf(t *testing.T, a *Answerer, req AskRequest) string {
|
||||
t.Helper()
|
||||
resp := a.Answer(context.Background(), req)
|
||||
if resp.Answer == nil {
|
||||
t.Fatalf("answer = nil, reason = %q", resp.Reason)
|
||||
}
|
||||
return *resp.Answer
|
||||
}
|
||||
|
||||
func TestTodayQuestion(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
got := answerOf(t, a, AskRequest{
|
||||
Question: "what does Harry have on today at school?",
|
||||
Asker: "sam",
|
||||
Person: "harry",
|
||||
Source: "esp32",
|
||||
})
|
||||
// 5 lessons, 1 task due today, 2 overdue.
|
||||
if !strings.Contains(got, "5 school events today from 8:55am to 6:00pm") {
|
||||
t.Errorf("answer = %q, want the lesson count and span", got)
|
||||
}
|
||||
if !strings.Contains(got, "1 task due today") {
|
||||
t.Errorf("answer = %q, want 1 task due today", got)
|
||||
}
|
||||
if !strings.Contains(got, "2 overdue tasks") {
|
||||
t.Errorf("answer = %q, want 2 overdue tasks", got)
|
||||
}
|
||||
if strings.Count(got, ".") != 1 {
|
||||
t.Errorf("answer = %q, want one sentence", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayQuestionIsCorrectForRealShape(t *testing.T) {
|
||||
// The Hub lower-cases the person, and the status line uses the HTML grid
|
||||
// prefix. Both must resolve the same way.
|
||||
a := testAnswerer(harryData())
|
||||
resp := a.Answer(context.Background(), AskRequest{Question: "what has harry got on today?", Asker: "sam", Person: "Harry"})
|
||||
if resp.Answer == nil {
|
||||
t.Fatalf("answer = nil, reason = %q", resp.Reason)
|
||||
}
|
||||
if resp.Data["child"] != "Harry" {
|
||||
t.Errorf("data.child = %v, want Harry", resp.Data["child"])
|
||||
}
|
||||
if resp.Expires == "" {
|
||||
t.Error("expires is empty, want end of day")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverdueFocus(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
got := answerOf(t, a, AskRequest{Question: "does Harry have any overdue work?", Asker: "sam", Person: "harry"})
|
||||
if got != "Harry has 2 overdue tasks." {
|
||||
t.Errorf("answer = %q, want the overdue count only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesFocus(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
got := answerOf(t, a, AskRequest{Question: "any school notices for Harry?", Asker: "sam", Person: "harry"})
|
||||
if got != "Harry has 1 school notice." {
|
||||
t.Errorf("answer = %q, want one notice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekFocus(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
got := answerOf(t, a, AskRequest{Question: "what is due this week for Harry?", Asker: "sam", Person: "harry"})
|
||||
if !strings.Contains(got, "1 task due this week") {
|
||||
t.Errorf("answer = %q, want the week count", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedScanIsLoud is the FR-12 promise: a failed scan is never an empty
|
||||
// day. The answer says due work is unknown.
|
||||
func TestFailedScanIsLoud(t *testing.T) {
|
||||
r := harryData()
|
||||
r.scans[1] = store.ScanStatus{EverRun: true, Total: 4, Failed: 1,
|
||||
Last: &store.Scan{ID: 4, StartedAt: testNow.Add(-2 * time.Hour), OK: false, Error: "cloudflare challenge"}}
|
||||
a := testAnswerer(r)
|
||||
resp := a.Answer(context.Background(), AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
|
||||
if resp.Answer == nil {
|
||||
t.Fatal("answer = nil, want a loud answer")
|
||||
}
|
||||
got := *resp.Answer
|
||||
if !strings.Contains(got, "failed") || !strings.Contains(got, "unknown") {
|
||||
t.Errorf("answer = %q, want the failed scan stated", got)
|
||||
}
|
||||
if strings.Contains(got, "nothing due") {
|
||||
t.Errorf("answer = %q, must not read as nothing due", got)
|
||||
}
|
||||
if resp.Data["scan_ok"] != false {
|
||||
t.Errorf("data.scan_ok = %v, want false", resp.Data["scan_ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeverScannedIsLoud(t *testing.T) {
|
||||
r := harryData()
|
||||
r.scans[1] = store.ScanStatus{}
|
||||
a := testAnswerer(r)
|
||||
got := answerOf(t, a, AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
|
||||
if !strings.Contains(got, "has not run") {
|
||||
t.Errorf("answer = %q, want the missing scan stated", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleScanIsQualified(t *testing.T) {
|
||||
r := harryData()
|
||||
r.scans[1] = okScan(testNow.Add(-50 * time.Hour))
|
||||
a := testAnswerer(r)
|
||||
got := answerOf(t, a, AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
|
||||
if !strings.Contains(got, "may be out of date") {
|
||||
t.Errorf("answer = %q, want a stale warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPersonCannotAnswer(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
resp := a.Answer(context.Background(), AskRequest{Question: "what does Hogwarts have on today?", Asker: "sam"})
|
||||
if resp.Answer != nil {
|
||||
t.Fatalf("answer = %q, want nil", *resp.Answer)
|
||||
}
|
||||
if resp.Reason == "" {
|
||||
t.Error("reason is empty, want an explanation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildCannotAskAboutSibling(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
resp := a.Answer(context.Background(), AskRequest{Question: "what does Finn have on today?", Asker: "harry", Person: "finn"})
|
||||
if resp.Answer != nil {
|
||||
t.Fatalf("answer = %q, want nil", *resp.Answer)
|
||||
}
|
||||
if !strings.Contains(resp.Reason, "not allowed") {
|
||||
t.Errorf("reason = %q, want a permission refusal", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildMayAskAboutThemselves(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
resp := a.Answer(context.Background(), AskRequest{Question: "what do I have on today?", Asker: "harry", Person: "harry"})
|
||||
if resp.Answer == nil {
|
||||
t.Fatalf("answer = nil, reason = %q", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyQuestionCannotAnswer(t *testing.T) {
|
||||
a := testAnswerer(harryData())
|
||||
resp := a.Answer(context.Background(), AskRequest{Asker: "sam"})
|
||||
if resp.Answer != nil || resp.Reason == "" {
|
||||
t.Errorf("resp = %+v, want cannot-answer", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesNameMatchesRouting(t *testing.T) {
|
||||
caps := DefaultCapabilities()
|
||||
if caps.Project != "compass_nhs" {
|
||||
t.Errorf("project = %q, want compass_nhs (the Hub routing.md row)", caps.Project)
|
||||
}
|
||||
if len(caps.Topics) == 0 || len(caps.People) == 0 {
|
||||
t.Error("capabilities must name topics and people")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user