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:
309
internal/web/server_test.go
Normal file
309
internal/web/server_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user