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 }