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") } }