- 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>
133 lines
4.1 KiB
Go
133 lines
4.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestItemsForChildAndDelete covers the Phase 5 reads and the Delete half of
|
|
// FR-10: one child's items are read alone, one item is fetched by id, and
|
|
// deleting it removes exactly one row.
|
|
func TestItemsForChildAndDelete(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
|
|
harry, _ := st.ChildByName(ctx, "Harry")
|
|
finn, _ := st.ChildByName(ctx, "Finn")
|
|
|
|
due := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
|
|
if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{
|
|
{Source: "compass", Type: "task", Subject: "English", Title: "Essay", ExternalRef: "h1", DueAt: &due, Body: "Status: Overdue"},
|
|
{Source: "compass", Type: "message", Title: "Notice", ExternalRef: "h2"},
|
|
}); err != nil {
|
|
t.Fatalf("upsert harry: %v", err)
|
|
}
|
|
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
|
|
{Source: "compass", Type: "task", Title: "Lab report", ExternalRef: "f1"},
|
|
}); err != nil {
|
|
t.Fatalf("upsert finn: %v", err)
|
|
}
|
|
|
|
rows, err := st.ItemsForChild(ctx, harry.ID)
|
|
if err != nil {
|
|
t.Fatalf("items for child: %v", err)
|
|
}
|
|
if len(rows) != 2 {
|
|
t.Fatalf("harry rows = %d, want 2", len(rows))
|
|
}
|
|
var essay *Item
|
|
for i := range rows {
|
|
if rows[i].ChildID != harry.ID {
|
|
t.Errorf("row belongs to child %d", rows[i].ChildID)
|
|
}
|
|
if rows[i].ExternalRef == "h1" {
|
|
essay = &rows[i]
|
|
}
|
|
}
|
|
if essay == nil || essay.DueAt == nil || !essay.DueAt.Equal(due) {
|
|
t.Fatalf("essay due not stored: %+v", essay)
|
|
}
|
|
|
|
got, err := st.ItemByID(ctx, essay.ID)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("item by id: %v %v", got, err)
|
|
}
|
|
if got.Title != "Essay" || got.Child != "Harry" {
|
|
t.Errorf("item by id = %+v", got)
|
|
}
|
|
|
|
n, err := st.DeleteItemByID(ctx, essay.ID)
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("delete = %d, %v", n, err)
|
|
}
|
|
if got, _ := st.ItemByID(ctx, essay.ID); got != nil {
|
|
t.Error("item survived delete")
|
|
}
|
|
if rows, _ := st.ItemsForChild(ctx, finn.ID); len(rows) != 1 {
|
|
t.Errorf("finn rows = %d, want 1 (delete must not cross children)", len(rows))
|
|
}
|
|
// Deleting a missing id is a quiet no-op, not an error.
|
|
if n, err := st.DeleteItemByID(ctx, essay.ID); err != nil || n != 0 {
|
|
t.Errorf("second delete = %d, %v", n, err)
|
|
}
|
|
}
|
|
|
|
// TestSummariesRoundTrip covers the stored brief: create a draft, list it,
|
|
// fetch it and delete it (FR-10 summary half).
|
|
func TestSummariesRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
|
|
id, err := st.CreateSummary(ctx, "Harry", "Harry — 1 overdue.\n\nOVERDUE\n- Essay\n")
|
|
if err != nil || id == 0 {
|
|
t.Fatalf("create: %v id=%d", err, id)
|
|
}
|
|
list, err := st.ListSummaries(ctx, 10)
|
|
if err != nil || len(list) != 1 {
|
|
t.Fatalf("list: %v len=%d", err, len(list))
|
|
}
|
|
if list[0].Recipient != "Harry" || list[0].SentAt != nil {
|
|
t.Errorf("summary = %+v, want draft for Harry", list[0])
|
|
}
|
|
got, err := st.SummaryByID(ctx, id)
|
|
if err != nil || got == nil || got.Text != list[0].Text {
|
|
t.Fatalf("by id: %v %+v", err, got)
|
|
}
|
|
n, err := st.DeleteSummary(ctx, id)
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("delete = %d, %v", n, err)
|
|
}
|
|
if got, _ := st.SummaryByID(ctx, id); got != nil {
|
|
t.Error("summary survived delete")
|
|
}
|
|
}
|
|
|
|
// TestScanSummaryForChild proves a failed scan for one child is visible for
|
|
// that child even when the other child has not been scanned (FR-12).
|
|
func TestScanSummaryForChild(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
harry, _ := st.ChildByName(ctx, "Harry")
|
|
finn, _ := st.ChildByName(ctx, "Finn")
|
|
|
|
if _, err := st.RecordScan(ctx, &finn.ID, 0, 0, false, "login challenge"); err != nil {
|
|
t.Fatalf("record: %v", err)
|
|
}
|
|
if _, err := st.RecordScan(ctx, &harry.ID, 3, 12, true, ""); err != nil {
|
|
t.Fatalf("record: %v", err)
|
|
}
|
|
|
|
hs, err := st.ScanSummaryForChild(ctx, harry.ID)
|
|
if err != nil || !hs.EverRun || hs.Last == nil || !hs.Last.OK {
|
|
t.Fatalf("harry scan summary = %+v, %v", hs, err)
|
|
}
|
|
fs, err := st.ScanSummaryForChild(ctx, finn.ID)
|
|
if err != nil || !fs.EverRun || fs.Last == nil || fs.Last.OK {
|
|
t.Fatalf("finn scan summary = %+v, %v", fs, err)
|
|
}
|
|
if fs.Failed != 1 {
|
|
t.Errorf("finn failed = %d, want 1", fs.Failed)
|
|
}
|
|
}
|