Phase 6b/7b: dashboard codes editor, decode events, human-readable briefs (AUD-115)

- New /codes dashboard page (behind the session login, linked in the top bar):
  lists every mapping by kind with Add, inline Edit+Save and Delete. Sam can put
  real teacher surnames in without a rebuild (FR-17).
- Decode timetable EVENTS through the same map as periods, so an ensemble reads
  'JAZZ ENSEMBLE - Ensemble Room - Mr KWO' instead of raw codes.
- teacherName splits comma- or space-separated codes ('HHL, OFG').
- SeedCodes is a one-time bootstrap while the table is empty, so a correction or
  a delete is never overwritten or restored.
- Tests: decode Label/Event, the /codes page (save, refuse, csrf, delete, auth),
  store re-seed behaviour. go test ./internal/... passes.
This commit is contained in:
2026-10-16 12:50:31 +11:00
parent 80b54524ea
commit 3e25011399
11 changed files with 433 additions and 28 deletions

View File

@@ -875,3 +875,91 @@ func TestMorningEndpointDryRun(t *testing.T) {
t.Errorf("a dry run must not record deliveries: %+v", rows)
}
}
// TestCodesPageEditsDecodeMap proves FR-17 in the dashboard: Sam can see the
// decode map, correct a teacher initial to a surname, and remove a mapping.
// A corrected or deleted mapping must stick, not reappear from the seed.
func TestCodesPageEditsDecodeMap(t *testing.T) {
ts, st := newTestServer(t)
client := login(t, ts)
page := getBody(t, client, ts.URL+"/codes")
if !strings.Contains(page, "KNI") || !strings.Contains(page, "Teachers") {
t.Fatalf("codes page does not list the seeded teacher codes")
}
token := csrfToken(t, page)
// Correct an initial to a surname.
resp, err := client.PostForm(ts.URL+"/codes", url.Values{
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Knight"},
})
if err != nil {
t.Fatalf("save: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", resp.StatusCode)
}
codes, err := st.Codes(context.Background())
if err != nil {
t.Fatalf("codes: %v", err)
}
if codes.Teacher["KNI"] != "Mr Knight" {
t.Errorf("teacher KNI = %q, want Mr Knight", codes.Teacher["KNI"])
}
// An empty name is refused and the mapping is unchanged.
resp, err = client.PostForm(ts.URL+"/codes", url.Values{
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {""},
})
if err != nil {
t.Fatalf("bad save: %v", err)
}
resp.Body.Close()
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "level=error") {
t.Errorf("bad save Location = %q, want an error flash", loc)
}
if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "Mr Knight" {
t.Errorf("the refused save changed the mapping to %q", codes.Teacher["KNI"])
}
// A bad CSRF token is refused.
resp, err = client.PostForm(ts.URL+"/codes", url.Values{
"csrf": {"wrong"}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Nope"},
})
if err != nil {
t.Fatalf("csrf save: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("bad csrf status = %d, want 403", resp.StatusCode)
}
// Delete returns the code to raw, and it stays deleted.
resp, err = client.PostForm(ts.URL+"/codes/delete", url.Values{
"csrf": {token}, "kind": {"teacher"}, "code": {"KNI"},
})
if err != nil {
t.Fatalf("delete: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("delete status = %d, want 303", resp.StatusCode)
}
if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "" {
t.Errorf("teacher KNI still mapped after delete: %q", codes.Teacher["KNI"])
}
// The page is behind the session login.
anon := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
anonResp, err := anon.Get(ts.URL + "/codes")
if err != nil {
t.Fatalf("anon get: %v", err)
}
anonResp.Body.Close()
if anonResp.StatusCode != http.StatusSeeOther {
t.Errorf("anonymous /codes = %d, want a redirect to login", anonResp.StatusCode)
}
}