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 }