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:
2026-10-15 19:47:32 +11:00
parent 09639c5125
commit 9cbfa7db9b
13 changed files with 1720 additions and 19 deletions

498
internal/hub/hub.go Normal file
View File

@@ -0,0 +1,498 @@
// Package hub implements the project side of the frozen Hub contract
// (central_ai_llm_master/docs/ask-contract.md) for the Compass / NHS service:
//
// GET /capabilities -> the topics this project can answer (Hub routing)
// POST /ask -> a short, read-only answer about school work
// POST /notify -> accept the contract's notification shape
//
// The Hub is the expert on routing and reply shape. It must never learn this
// project's data model, so /ask answers in one short sentence and never exposes
// a token, a path or a database id. /ask and /capabilities are read-only: they
// call only store read methods, so "read only" is a testable property
// (TestAskReadsOnly), not a comment (AGENTS.md §10 rule 5, FR-11).
//
// The answers are deterministic. No model is in the path: the Hub already caps
// the call at 3 seconds, and a school question must answer on the first try.
package hub
import (
"context"
"fmt"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// ProjectName is the value the Hub matches in routing.md. The Hub's topic map
// (and central_ai_llm_master/routing.md) already names this project
// `compass_nhs`, and the contract requires /capabilities to report the same
// name, so this is the Hub-facing name. The repository, module and service keep
// the fuller "Compass / NHS" name.
const ProjectName = "compass_nhs"
// DefaultStaleAfter is how old the last successful scan may be before /ask says
// so. The 6:00am schedule scans daily, so a day and a half means the schedule
// did not run (AGENTS.md §13 decision 7).
const DefaultStaleAfter = 36 * time.Hour
// Capabilities is the GET /capabilities reply. The Hub reads it to route a
// question to this project. Keep the topic list short and plain; it holds no
// private data.
type Capabilities struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
// DefaultCapabilities returns the topics this project answers. They match the
// school domain in AGENTS.md §1 and §4 and the Hub's routing.md row.
func DefaultCapabilities() Capabilities {
return Capabilities{
Project: ProjectName,
Topics: []string{"school", "compass", "homework", "timetable", "class", "overdue", "school notices"},
People: []string{"harry", "finn"},
}
}
// AskRequest is the POST /ask body (ask-contract.md).
type AskRequest struct {
Question string `json:"question"`
Asker string `json:"asker"`
Person string `json:"person,omitempty"`
Source string `json:"source,omitempty"`
}
// AskResponse is the POST /ask reply. A nil Answer means the project cannot
// answer and Reason explains why. The shape is frozen by the contract.
type AskResponse struct {
Answer *string `json:"answer"`
Data map[string]any `json:"data,omitempty"`
Expires string `json:"expires,omitempty"`
Reason string `json:"reason,omitempty"`
}
// NotifyRequest is the POST /notify body (ask-contract.md, "Notify"). The
// contract defines it as project-to-Hub; this service accepts the same shape so
// the Hub (or a future caller) can have a person notified without inventing a
// second shape. Delivery is the only write on this surface.
type NotifyRequest struct {
From string `json:"from"`
Level string `json:"level"`
Message string `json:"message"`
Person string `json:"person,omitempty"`
}
// Reader is the read-only slice of the store that /ask and /capabilities use.
// It is an interface so the answer logic is testable without a database, and so
// the "read only" promise is enforced by the type: there is no write method to
// call.
type Reader interface {
ListChildren(ctx context.Context) ([]store.Child, error)
ItemsForChild(ctx context.Context, childID int64) ([]store.Item, error)
ScanSummaryForChild(ctx context.Context, childID int64) (store.ScanStatus, error)
}
// Answerer answers /ask from stored school items. It holds no state of its own
// beyond the clock, so the same instance serves every request.
type Answerer struct {
reader Reader
loc *time.Location
// Now is the clock. It is a field so tests are deterministic.
Now func() time.Time
// StaleAfter is how old the last scan may be before the answer says so.
// Zero means DefaultStaleAfter.
StaleAfter time.Duration
// Parents are the askers who may see any child. Every other asker may see
// only themselves (ask-contract.md, "Rules for the project" rule 2).
Parents []string
}
// NewAnswerer builds an answerer for the given reader and family timezone.
func NewAnswerer(reader Reader, loc *time.Location) *Answerer {
if loc == nil {
loc = time.UTC
}
return &Answerer{
reader: reader,
loc: loc,
Now: time.Now,
StaleAfter: DefaultStaleAfter,
Parents: []string{"sam", "jo"},
}
}
// SetParents replaces the askers who may see any child.
func (a *Answerer) SetParents(names ...string) {
clean := make([]string, 0, len(names))
for _, n := range names {
if n = normalize(n); n != "" {
clean = append(clean, n)
}
}
if len(clean) > 0 {
a.Parents = clean
}
}
// Answer answers one question about this project's own data. It always returns
// a value: either an answer or a reason. It never guesses and never returns an
// answer for a question it cannot place.
func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
if strings.TrimSpace(req.Question) == "" {
return cannotAnswer("the question is required")
}
asker := normalize(req.Asker)
if asker == "" {
return cannotAnswer("the asker is required")
}
person := normalize(req.Person)
if person == "" {
person = asker
}
children, err := a.reader.ListChildren(ctx)
if err != nil {
return cannotAnswer("the school data could not be read")
}
child := findChild(children, person)
if child == nil {
return cannotAnswer("no data for that person")
}
if !a.maySee(asker, child.Name) {
return cannotAnswer("that person is not allowed to ask about " + child.Name)
}
items, err := a.reader.ItemsForChild(ctx, child.ID)
if err != nil {
return cannotAnswer("the school data could not be read")
}
scan, err := a.reader.ScanSummaryForChild(ctx, child.ID)
if err != nil {
return cannotAnswer("the scan state could not be read")
}
now := a.Now().In(a.loc)
buckets := dashboard.Classify(items, now, a.loc)
state := scanHealthOf(scan, now, a.staleAfter())
resp := a.compose(child, buckets, state, req.Question, now)
return resp
}
// scanHealth is the fail-loudly view of the scan for one child (FR-12). It never
// lets a failed or missing scan read as "nothing due".
type scanHealth struct {
OK bool
EverRun bool
Stale bool
Age time.Duration
At *time.Time
Error string
}
func scanHealthOf(st store.ScanStatus, now time.Time, staleAfter time.Duration) scanHealth {
s := scanHealth{EverRun: st.EverRun}
if st.Last == nil {
return s
}
at := st.Last.StartedAt.In(now.Location())
s.At = &at
s.Age = now.Sub(at)
if !st.Last.OK {
s.Error = strings.TrimSpace(st.Last.Error)
return s
}
s.OK = true
s.Stale = staleAfter > 0 && s.Age > staleAfter
return s
}
// compose turns one child's buckets into the contract's reply. The answer is
// one short sentence (with a second sentence only when the data is stale), and
// the structured detail lives in `data` for the Hub to ignore or use.
func (a *Answerer) compose(child *store.Child, b dashboard.Buckets, scan scanHealth, question string, now time.Time) AskResponse {
data := map[string]any{
"child": child.Name,
"scan_ok": scan.OK,
"scan_stale": scan.Stale,
}
if scan.At != nil {
data["scan_at"] = scan.At.UTC().Format(time.RFC3339)
}
if scan.Age > 0 {
data["scan_age_hours"] = int(scan.Age.Hours())
}
if scan.Error != "" {
data["scan_error"] = scan.Error
}
// Fail loudly: a missing or failed scan is reported as its own answer, never
// as an empty day (FR-12, AGENTS.md §10 rule 2).
if !scan.OK {
return AskResponse{
Answer: answerText(unavailableAnswer(child.Name, scan)),
Data: data,
}
}
data["overdue"] = itemList(b.Overdue, a.loc)
data["due_today"] = itemList(b.DueToday, a.loc)
data["due_week"] = itemList(b.DueWeek, a.loc)
data["messages"] = itemList(b.Messages, a.loc)
text := a.sentence(child.Name, b, focusFor(question), a.loc)
if scan.Stale {
text += " " + staleNote(child.Name, scan.Age)
}
return AskResponse{
Answer: answerText(text),
Data: data,
Expires: endOfDay(now).UTC().Format(time.RFC3339),
}
}
// focus is the part of the data the question asks about.
type focus int
const (
focusAll focus = iota
focusToday
focusWeek
focusOverdue
focusMessages
)
// focusFor reads the question. It is deliberately simple: the Hub routes the
// question, so this project only has to place the few school shapes. An unknown
// question still answers with the whole picture rather than guessing a bucket.
func focusFor(question string) focus {
q := strings.ToLower(question)
switch {
case contains(q, "overdue", "not done", "outstanding", "late work", "behind"):
return focusOverdue
case contains(q, "this week", "next week", "week"):
return focusWeek
case contains(q, "message", "notice", "news", "email", "announcement"):
return focusMessages
case contains(q, "today", "timetable", "schedule", "class", "period", "lesson", "at school", "on at school"):
return focusToday
case contains(q, "due", "homework", "assignment", "assessment", "task", "sac", "exam"):
return focusAll
default:
return focusAll
}
}
// sentence builds the one-sentence answer for a focus. Every part is a phrase
// ("8 classes today from 8:55am", "2 overdue tasks"), so the joined sentence
// stays grammatical.
func (a *Answerer) sentence(name string, b dashboard.Buckets, f focus, loc *time.Location) string {
events := onlyType(b.DueToday, "event")
today := onlyType(b.DueToday, "task")
var parts []string
add := func(s string) {
if s != "" {
parts = append(parts, s)
}
}
overdue := countPhrase(len(b.Overdue), "overdue task")
week := countPhrase(len(b.DueWeek), "task due this week")
switch f {
case focusOverdue:
add(overdue)
if len(parts) == 0 {
add(countPhrase(len(today), "task due today"))
}
case focusWeek:
add(overdue)
add(week)
case focusMessages:
add(countPhrase(len(b.Messages), "school notice"))
case focusToday:
add(eventsPhrase(events, loc))
add(countPhrase(len(today), "task due today"))
add(overdue)
default: // focusAll
add(eventsPhrase(events, loc))
add(countPhrase(len(today), "task due today"))
add(overdue)
add(week)
}
if len(parts) == 0 {
return name + " has nothing due today."
}
return name + " has " + joinParts(parts) + "."
}
// eventsPhrase is the today's-schedule part: "8 classes today from 8:55am".
func eventsPhrase(events []store.Item, loc *time.Location) string {
if len(events) == 0 {
return ""
}
first, last := time.Time{}, time.Time{}
for _, it := range events {
if it.DueAt == nil {
continue
}
t := it.DueAt.In(loc)
if first.IsZero() || t.Before(first) {
first = t
}
if last.IsZero() || t.After(last) {
last = t
}
}
word := "events"
if len(events) == 1 {
word = "event"
}
if first.IsZero() {
return fmt.Sprintf("%d school %s today", len(events), word)
}
if first.Format("15:04") == last.Format("15:04") {
return fmt.Sprintf("%d school %s today from %s", len(events), word, clock(first))
}
return fmt.Sprintf("%d school %s today from %s to %s", len(events), word, clock(first), clock(last))
}
// unavailableAnswer is the fail-loudly sentence for a missing or failed scan.
func unavailableAnswer(name string, scan scanHealth) string {
if !scan.EverRun {
return fmt.Sprintf("I have no school data for %s yet: the Compass scan has not run, so due work is unknown.", name)
}
if scan.At != nil {
return fmt.Sprintf("I cannot say what %s has due: the last Compass scan on %s failed, so due work is unknown.",
name, scan.At.In(time.UTC).Format("Mon 02 Jan 15:04 MST"))
}
return fmt.Sprintf("I cannot say what %s has due: the last Compass scan failed, so due work is unknown.", name)
}
// staleNote qualifies an answer built on old data, so an out-of-date list is
// never read as today's work.
func staleNote(name string, age time.Duration) string {
days := int(age.Hours() / 24)
if days >= 2 {
return fmt.Sprintf("The last Compass scan for %s was %d days ago, so this may be out of date.", name, days)
}
return fmt.Sprintf("The last Compass scan for %s was %d hours ago, so this may be out of date.", name, int(age.Hours()))
}
// maySee checks the contract's permission rule: a parent may see any child, and
// anyone else may see only themselves.
func (a *Answerer) maySee(asker, child string) bool {
a2, c := normalize(asker), normalize(child)
if a2 == c {
return true
}
for _, p := range a.Parents {
if normalize(p) == a2 {
return true
}
}
return false
}
func (a *Answerer) staleAfter() time.Duration {
if a.StaleAfter > 0 {
return a.StaleAfter
}
return DefaultStaleAfter
}
// findChild matches a name case-insensitively: the Hub lower-cases names, while
// the database stores "Harry".
func findChild(children []store.Child, name string) *store.Child {
for i := range children {
if normalize(children[i].Name) == name {
return &children[i]
}
}
return nil
}
func onlyType(items []store.Item, typ string) []store.Item {
out := make([]store.Item, 0, len(items))
for _, it := range items {
if it.Type == typ {
out = append(out, it)
}
}
return out
}
// itemList is the structured detail for `data`. It holds no secret and no path.
func itemList(items []store.Item, loc *time.Location) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, it := range items {
m := map[string]any{
"type": it.Type,
"title": strings.TrimSpace(it.Title),
}
if s := strings.TrimSpace(it.Subject); s != "" {
m["subject"] = s
}
if t := strings.TrimSpace(it.Teacher); t != "" {
m["teacher"] = t
}
if it.DueAt != nil {
m["due_at"] = it.DueAt.In(loc).Format(time.RFC3339)
}
out = append(out, m)
}
return out
}
func countPhrase(n int, noun string) string {
switch {
case n == 0:
return ""
case n == 1:
return "1 " + noun
default:
return fmt.Sprintf("%d %ss", n, noun)
}
}
// joinParts renders "a", "a and b" or "a, b and c".
func joinParts(parts []string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
case 2:
return parts[0] + " and " + parts[1]
default:
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
}
}
func contains(haystack string, needles ...string) bool {
for _, n := range needles {
if strings.Contains(haystack, n) {
return true
}
}
return false
}
func clock(t time.Time) string { return t.Format("3:04pm") }
func endOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
}
func normalize(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// answerText is a small helper so pointer handling stays in one place.
func answerText(s string) *string { return &s }
// cannotAnswer builds the contract's "cannot answer" reply.
func cannotAnswer(reason string) AskResponse {
return AskResponse{Answer: nil, Reason: reason}
}

244
internal/hub/hub_test.go Normal file
View 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")
}
}