Phase 4: per-child sessions, scan both children, items separated
Fetch and parse Harry and Finn with a stored Compass session per child and store every item against the right children.id. - cmd/school/session.go + internal/store/session.go: read and write the per-child harness session state in the SQLite `session` table. `show` prints metadata only; the state is never printed. - scripts/compass-fetch-patchright.mjs: reuse the stored session, refresh it after login, resolve the child's userId from the parent page by name, and keep the shared calendar out of the per-child run (it would leak the eldest's timetable into the younger child). - scripts/scan-compass.sh + make scan: scan one child or both; restore the session, fetch, persist the session, then parse. A failing child is recorded in `scans` with ok = 0 and the script exits non-zero (FR-12). - cmd/school/scan.go: `school scan record` writes a fetch failure to the scans table. cmd/school/items.go: `school items purge` removes rows that a shared page stored against the wrong child. - internal/store/store_test.go: prove items and sessions are separated by child. docs/phase-4-both-children-notes.md; README + image tag 0.3.0-phase4. Live evidence: Harry 70 tasks / 1 message / 35 events (6 overdue); Finn 42 tasks / 1 message / 0 events (2 overdue). Finn's subjects are Year 7, Harry's are Year 11. Both sessions stored. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
90
internal/store/session.go
Normal file
90
internal/store/session.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Per-child harness session state (AGENTS.md §7, FR-2, §11 Phase 4).
|
||||
//
|
||||
// The Compass fetcher (patchright) keeps a browser storage state — cookies and
|
||||
// localStorage — so a later scan can reuse the login instead of signing in
|
||||
// again. That state is a secret: it lives in the database volume only, is never
|
||||
// exported to git, never logged, and never printed by a command.
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChildSession is the stored harness session for one child.
|
||||
type ChildSession struct {
|
||||
ChildID int64
|
||||
Child string
|
||||
State string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PutSession writes (or refreshes) the harness session state for one child. The
|
||||
// state is opaque to the store; the fetcher owns its format.
|
||||
func (s *Store) PutSession(ctx context.Context, childID int64, state string) error {
|
||||
if state == "" {
|
||||
return fmt.Errorf("put session: state is empty")
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO session (child_id, state, updated_at) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(child_id) DO UPDATE SET state = excluded.state, updated_at = datetime('now')`,
|
||||
childID, state); err != nil {
|
||||
return fmt.Errorf("put session for child %d: %w", childID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession returns the stored session for one child, or nil when it has none.
|
||||
func (s *Store) GetSession(ctx context.Context, childID int64) (*ChildSession, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT se.child_id, COALESCE(ch.name, ''), se.state, se.updated_at
|
||||
FROM session se LEFT JOIN children ch ON ch.id = se.child_id
|
||||
WHERE se.child_id = ?`, childID)
|
||||
var cs ChildSession
|
||||
var updated string
|
||||
if err := row.Scan(&cs.ChildID, &cs.Child, &cs.State, &updated); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get session for child %d: %w", childID, err)
|
||||
}
|
||||
cs.UpdatedAt = parseTime(updated)
|
||||
return &cs, nil
|
||||
}
|
||||
|
||||
// ListSessions returns the stored sessions, oldest child first. Each state is
|
||||
// returned in full; callers that print must never print the state.
|
||||
func (s *Store) ListSessions(ctx context.Context) ([]ChildSession, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT se.child_id, COALESCE(ch.name, ''), se.state, se.updated_at
|
||||
FROM session se LEFT JOIN children ch ON ch.id = se.child_id
|
||||
ORDER BY se.child_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ChildSession
|
||||
for rows.Next() {
|
||||
var cs ChildSession
|
||||
var updated string
|
||||
if err := rows.Scan(&cs.ChildID, &cs.Child, &cs.State, &updated); err != nil {
|
||||
return nil, fmt.Errorf("scan session: %w", err)
|
||||
}
|
||||
cs.UpdatedAt = parseTime(updated)
|
||||
out = append(out, cs)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteSession removes the stored session for one child. A signed-out or
|
||||
// rejected login is not a failure; it just means the next scan logs in again.
|
||||
func (s *Store) DeleteSession(ctx context.Context, childID int64) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM session WHERE child_id = ?`, childID); err != nil {
|
||||
return fmt.Errorf("delete session for child %d: %w", childID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
181
internal/store/store_test.go
Normal file
181
internal/store/store_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestStore opens a fresh SQLite database in a temp dir.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := 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)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// TestItemsAreSeparatedByChild proves the Phase 4 DoD: items are stored against
|
||||
// the right children.id and nothing leaks between children.
|
||||
func TestItemsAreSeparatedByChild(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
harry, err := st.ChildByName(ctx, "Harry")
|
||||
if err != nil || harry == nil {
|
||||
t.Fatalf("harry: %v %v", harry, err)
|
||||
}
|
||||
finn, err := st.ChildByName(ctx, "Finn")
|
||||
if err != nil || finn == nil {
|
||||
t.Fatalf("finn: %v %v", finn, err)
|
||||
}
|
||||
if harry.ID == finn.ID {
|
||||
t.Fatal("children share an id")
|
||||
}
|
||||
|
||||
harryItems := []Item{
|
||||
{Source: "compass", Type: "task", Subject: "English", Title: "Essay", ExternalRef: "h1"},
|
||||
{Source: "compass", Type: "task", Subject: "Maths", Title: "Chapter 4", ExternalRef: "h2"},
|
||||
{Source: "compass", Type: "message", Title: "Notice", ExternalRef: "h3"},
|
||||
}
|
||||
finnItems := []Item{
|
||||
{Source: "compass", Type: "task", Subject: "Science", Title: "Lab report", ExternalRef: "f1"},
|
||||
}
|
||||
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, harryItems); err != nil {
|
||||
t.Fatalf("upsert harry: %v", err)
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, finn.ID, finnItems); err != nil {
|
||||
t.Fatalf("upsert finn: %v", err)
|
||||
}
|
||||
|
||||
// The same external ref under each child must be two rows, not a conflict.
|
||||
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
|
||||
{Source: "compass", Type: "task", Title: "Finn essay", ExternalRef: "h1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert finn duplicate ref: %v", err)
|
||||
}
|
||||
|
||||
counts, err := st.CountItemsByType(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("counts: %v", err)
|
||||
}
|
||||
got := map[string]int{}
|
||||
for _, c := range counts {
|
||||
got[c.Child+"/"+c.Type] = c.Count
|
||||
}
|
||||
want := map[string]int{
|
||||
"Harry/task": 2,
|
||||
"Harry/message": 1,
|
||||
"Finn/task": 2,
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("count %s = %d, want %d (all: %v)", k, got[k], v, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A per-child sample must never return the other child's rows.
|
||||
harryRows, err := st.SampleItems(ctx, harry.ID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("sample harry: %v", err)
|
||||
}
|
||||
if len(harryRows) != 3 {
|
||||
t.Fatalf("harry rows = %d, want 3", len(harryRows))
|
||||
}
|
||||
for _, r := range harryRows {
|
||||
if r.ChildID != harry.ID {
|
||||
t.Errorf("harry sample returned child %d", r.ChildID)
|
||||
}
|
||||
}
|
||||
finnRows, err := st.SampleItems(ctx, finn.ID, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("sample finn: %v", err)
|
||||
}
|
||||
if len(finnRows) != 2 {
|
||||
t.Fatalf("finn rows = %d, want 2", len(finnRows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionRoundTrip proves the per-child session state is stored, read back
|
||||
// and separated.
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
finn, _ := st.ChildByName(ctx, "Finn")
|
||||
|
||||
if sess, err := st.GetSession(ctx, harry.ID); err != nil || sess != nil {
|
||||
t.Fatalf("empty session = %v, %v", sess, err)
|
||||
}
|
||||
|
||||
want := `{"cookies":[{"name":"x"}],"origins":[]}`
|
||||
if err := st.PutSession(ctx, harry.ID, want); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
sess, err := st.GetSession(ctx, harry.ID)
|
||||
if err != nil || sess == nil {
|
||||
t.Fatalf("get: %v %v", sess, err)
|
||||
}
|
||||
if sess.State != want {
|
||||
t.Errorf("state mismatch")
|
||||
}
|
||||
if sess.Child != "Harry" {
|
||||
t.Errorf("child = %q", sess.Child)
|
||||
}
|
||||
|
||||
// Finn must not inherit Harry's session.
|
||||
if sess, err := st.GetSession(ctx, finn.ID); err != nil || sess != nil {
|
||||
t.Errorf("finn session = %v, %v", sess, err)
|
||||
}
|
||||
|
||||
// PutSession refreshes the same row.
|
||||
if err := st.PutSession(ctx, harry.ID, want+" "); err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
list, err := st.ListSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("sessions = %d, want 1", len(list))
|
||||
}
|
||||
|
||||
if err := st.DeleteSession(ctx, harry.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if sess, _ := st.GetSession(ctx, harry.ID); sess != nil {
|
||||
t.Errorf("session survived delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordScanFailure proves a failed child is recorded loudly (FR-12/FR-13).
|
||||
func TestRecordScanFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
|
||||
id, err := st.RecordScan(ctx, &harry.ID, 0, 0, false, "fetcher failed")
|
||||
if err != nil || id == 0 {
|
||||
t.Fatalf("record: %v id=%d", err, id)
|
||||
}
|
||||
last, err := st.LastScan(ctx)
|
||||
if err != nil || last == nil {
|
||||
t.Fatalf("last: %v %v", last, err)
|
||||
}
|
||||
if last.OK {
|
||||
t.Error("failed scan reported as ok")
|
||||
}
|
||||
if last.Error != "fetcher failed" {
|
||||
t.Errorf("error = %q", last.Error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user