Phase 5: per-child dashboard (overdue first), item detail, delete

- internal/dashboard: classify items into overdue / due today / due this week /
  no due date / messages; render the plain-text brief (FR-6, FR-7, FR-9).
- internal/store: ItemsForChild, ItemByID, DeleteItemByID, ScanSummaryForChild
  and the stored-brief CRUD helpers (FR-10).
- internal/web: routes /?child=, /items/{id}, POST /items/{id}/delete,
  POST /summaries/build, POST /summaries/{id}/delete, plus a per-child scan
  banner (FR-12) and a phone-first server-rendered template.
- Calendar events are never "overdue": a past lesson is history, not work.
- cmd/school brief build|list; scripts/screenshot-phase5.mjs for phone evidence.
- Tests: bucket classification, stored-brief round-trip, and HTTP routes
  including the NO SCAN / SCAN FAILED / healthy banner behaviour.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 17:50:29 +11:00
parent 207a4978a9
commit 37d82b1a0a
16 changed files with 2223 additions and 65 deletions

309
internal/web/server_test.go Normal file
View File

@@ -0,0 +1,309 @@
package web
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
_ "time/tzdata"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
const (
testUser = "sam"
testPass = "correct horse battery staple"
)
var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
// newTestServer builds the web handler against a fresh SQLite database.
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
t.Fatalf("location: %v", err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(context.Background(), "Harry", "Finn"); err != nil {
t.Fatalf("seed: %v", err)
}
cfg := &config.Config{
Addr: ":0",
DBPath: "test",
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
AdminUsername: testUser,
AdminPassword: testPass,
SessionTTL: time.Hour,
AppEnv: "test",
Version: "test",
Location: loc,
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
if err != nil {
t.Fatalf("auth: %v", err)
}
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("web: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
// login signs in and returns an HTTP client with the session cookie.
func login(t *testing.T, ts *httptest.Server) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("jar: %v", err)
}
client := &http.Client{Jar: jar}
page := getBody(t, client, ts.URL+"/login")
token := csrfToken(t, page)
form := url.Values{"csrf": {token}, "username": {testUser}, "password": {testPass}}
resp, err := client.PostForm(ts.URL+"/login", form)
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("login status = %d, want 200", resp.StatusCode)
}
// After login, do not follow redirects: a mutation's 303 is the assertion.
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return client
}
func getBody(t *testing.T, client *http.Client, url string) string {
t.Helper()
resp, err := client.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("get %s = %d, want 200", url, resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
return string(b)
}
func csrfToken(t *testing.T, html string) string {
t.Helper()
m := csrfRe.FindStringSubmatch(html)
if m == nil {
t.Fatalf("no csrf token in page")
}
return m[1]
}
// TestDashboardShowsChildItems proves FR-7: the selected child's items appear,
// overdue first, and another child's items do not leak in.
func TestDashboardShowsChildItems(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
overdue := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
today := time.Now().Add(2 * time.Hour)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
{Source: "compass", Type: "task", Subject: "11EENG", Title: "Overdue essay", ExternalRef: "h1", DueAt: &overdue, Body: "Status: Overdue"},
{Source: "compass", Type: "task", Subject: "11MGMA", Title: "Today chapter", ExternalRef: "h2", DueAt: &today, Body: "Status: Pending"},
}); err != nil {
t.Fatalf("upsert: %v", err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []store.Item{
{Source: "compass", Type: "task", Title: "Finn secret task", ExternalRef: "f1"},
}); err != nil {
t.Fatalf("upsert finn: %v", err)
}
client := login(t, ts)
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
for _, want := range []string{
"Overdue essay", "Today chapter", "Overdue", "Due today", "11EENG",
} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
if strings.Contains(body, "Finn secret task") {
t.Error("Harry's dashboard leaked Finn's item")
}
oi := strings.Index(body, "Overdue essay")
ti := strings.Index(body, "Today chapter")
if oi < 0 || ti < 0 || oi > ti {
t.Error("overdue item must render before the due-today item")
}
}
// TestItemDetailAndDelete proves §5 screen 3 and the Delete half of FR-10.
func TestItemDetailAndDelete(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
{Source: "compass", Type: "task", Subject: "11EENG", Teacher: "Ms Lee",
Title: "Essay draft", Body: "Status: Overdue\nClass: Class Task", ExternalRef: "h1"},
}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
id := items[0].ID
client := login(t, ts)
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
for _, want := range []string{"Essay draft", "11EENG", "Ms Lee", "Class Task"} {
if !strings.Contains(detail, want) {
t.Errorf("detail missing %q", want)
}
}
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete",
url.Values{"csrf": {csrfToken(t, detail)}})
if err != nil {
t.Fatalf("delete: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("delete status = %d, want 303", resp.StatusCode)
}
if got, _ := st.ItemByID(ctx, id); got != nil {
t.Error("item survived delete")
}
// The delete without a CSRF token is refused.
resp2, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete", url.Values{})
if err != nil {
t.Fatalf("delete2: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("tokenless delete = %d, want 403", resp2.StatusCode)
}
}
// TestFailLoudlyBanner proves FR-12: no scan is shown as NO SCAN, and a failed
// scan is shown as SCAN FAILED, never as an empty day.
func TestFailLoudlyBanner(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "NO SCAN HAS RUN YET") {
t.Error("missing NO SCAN HAS RUN YET banner")
}
if strings.Contains(body, "Nothing overdue.") {
t.Error("an unscanned child must not read as 'nothing overdue'")
}
if !strings.Contains(body, "Unknown — the last scan did not complete.") {
t.Error("missing the unknown empty state for a child with no scan")
}
if _, err := st.RecordScan(ctx, &harry.ID, 1, 0, false, "Cloudflare challenge"); err != nil {
t.Fatalf("record: %v", err)
}
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "SCAN FAILED") {
t.Error("missing SCAN FAILED banner")
}
if !strings.Contains(body, "Cloudflare challenge") {
t.Error("failure message not shown")
}
if _, err := st.RecordScan(ctx, &harry.ID, 4, 9, true, ""); err != nil {
t.Fatalf("record: %v", err)
}
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "Last scan completed.") {
t.Error("healthy scan banner missing")
}
if !strings.Contains(body, "Nothing overdue.") {
t.Error("a healthy scan should allow the quiet empty state")
}
}
// TestSummaryBuildAndDelete proves the stored brief round-trips through the
// dashboard (FR-10 summary half).
func TestSummaryBuildAndDelete(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
resp, err := client.PostForm(ts.URL+"/summaries/build",
url.Values{"csrf": {csrfToken(t, page)}, "child": {itoa(harry.ID)}})
if err != nil {
t.Fatalf("build: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("build status = %d, want 303", resp.StatusCode)
}
summaries, err := st.ListSummaries(ctx, 10)
if err != nil || len(summaries) != 1 {
t.Fatalf("summaries = %d, %v", len(summaries), err)
}
page = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "Harry — 0 overdue") {
t.Errorf("brief not shown on dashboard: %s", snippet(page))
}
resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete",
url.Values{"csrf": {csrfToken(t, page)}})
if err != nil {
t.Fatalf("delete: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusSeeOther {
t.Fatalf("summary delete = %d, want 303", resp2.StatusCode)
}
if got, _ := st.SummaryByID(ctx, summaries[0].ID); got != nil {
t.Error("summary survived delete")
}
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
func snippet(s string) string {
if len(s) > 400 {
return s[:400]
}
return s
}