Phase 4a: fetch and parse the authoritative Compass learning tasks

The Learning Tasks grid is capped at 20 rows and, on the parent page, mixes
both children, so it cannot answer "what is overdue". The parent home page
reported 6 overdue tasks for Harry while the stored grid held 1.

Fetch the per-child task list (read-only) from
POST /Services/LearningTasks.svc/GetAllLearningTasksByUserId, parse it into
task rows, and refuse to store it when the overdue count disagrees with the
home page summary (FR-12).

- fetcher: store learning-tasks-all.json + meta (userId, status, task count)
- parse: new learning-tasks-all page, JSON parser, home-page reconciliation
- store: drop superseded grid-derived task rows before the upsert
- docs: docs/phase-4a-overdue-tasks.md; close the Phase 3 known gap

Verified with live runs: Harry 70 tasks / 6 overdue, Finn 42 tasks / 2 overdue,
both equal to the home page; a tampered home page stops the parse.

Note: the fetcher and store.go files also carry the per-child session work
that was already uncommitted in this shared workspace (AUD-108).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 17:38:22 +11:00
parent d7d529eb0f
commit 5d263987dd
8 changed files with 913 additions and 82 deletions

View File

@@ -303,3 +303,81 @@ func TestClassifyDeterministic(t *testing.T) {
}
func ptr(t time.Time) *time.Time { return &t }
// tasksJSONFixture is the shape of GetAllLearningTasksByUserId: a wrapper with
// the per-child tasks. Task 1 is overdue and carries its due date at the task
// level; task 2 is on time and carries it on the student; task 3 belongs to the
// sibling and must be skipped.
const tasksJSONFixture = `{"d":{"total":3,"data":[
{"id":12657,"name":"SAC 6: Human Rights Presentation","activityName":"","subjectName":"11HLEG 11 HUMANITIES - LEGAL STUDIES","groupName":"11HLEG","dueDateTimestamp":"2026-10-09T05:00:00Z","students":[{"userId":27038,"userName":"Harrison ROLFE","submissionStatus":2,"dueDateTimestamp":"","submittedTimestamp":""}]},
{"id":14268,"name":"Unit 1 English Exam","activityName":"11EENG","subjectName":"11EENG 11 ENGLISH - ENGLISH","groupName":"11EENG","dueDateTimestamp":"","students":[{"userId":27038,"userName":"Harrison ROLFE","submissionStatus":3,"dueDateTimestamp":"2026-06-22T05:09:00Z","submittedTimestamp":"2026-06-22T05:08:00Z"}]},
{"id":99999,"name":"Finn task","activityName":"","subjectName":"7MAT","groupName":"7MAT","dueDateTimestamp":"","students":[{"userId":31460,"userName":"Finn ROLFE","submissionStatus":2}]}
]}}`
// homeOverdueFixture carries the parent home-page summary link for user 27038.
const homeOverdueFixture = `<html><body>
<div data-testid="feed-toolbar"></div>
<h5>Harrison ROLFE</h5>
<div class="parentChildActions-learningTaskItems">
<div><a href="Records/User.aspx?userId=27038#learningTasks">1 overdue learning task</a></div>
<div><a href="Records/User.aspx?userId=27038#learningTasks">1 learning task due in the next week</a></div>
</div>
</body></html>`
func TestParseLearningTasksAll(t *testing.T) {
loc := melbourne(t)
got, err := parseLearningTasksAll("Harry", Page{
Slug: PageLearningTasksAll,
UserID: "27038",
JSON: []byte(tasksJSONFixture),
}, loc)
if err != nil {
t.Fatalf("parseLearningTasksAll: %v", err)
}
if len(got.items) != 2 {
t.Fatalf("got %d items, want 2 (the sibling's row is skipped)", len(got.items))
}
if got.overdue != 1 {
t.Errorf("overdue = %d, want 1", got.overdue)
}
if got.total != 3 {
t.Errorf("total = %d, want 3", got.total)
}
if !strings.Contains(got.items[0].Body, "Status: Overdue") {
t.Errorf("overdue body = %q", got.items[0].Body)
}
if got.items[0].DueAt == nil || got.items[0].DueAt.Format("2006-01-02 15:04") != "2026-10-09 16:00" {
t.Errorf("task-level due = %v", got.items[0].DueAt)
}
if got.items[1].DueAt == nil || got.items[1].DueAt.Format("2006-01-02 15:04") != "2026-06-22 15:09" {
t.Errorf("student-level due = %v", got.items[1].DueAt)
}
if got.items[0].ExternalRef == got.items[1].ExternalRef {
t.Error("two tasks share an external ref")
}
if OverdueCount(got.items) != 1 {
t.Errorf("OverdueCount = %d, want 1", OverdueCount(got.items))
}
}
// TestParseReconcilesOverdueWithHome proves FR-12: the parser refuses to store a
// task list whose overdue count disagrees with the home page summary.
func TestParseReconcilesOverdueWithHome(t *testing.T) {
loc := melbourne(t)
jsonPage := Page{Slug: PageLearningTasksAll, UserID: "27038", JSON: []byte(tasksJSONFixture)}
homePage := Page{Slug: PageHome, HTML: []byte(homeOverdueFixture)}
if _, err := Parse("Harry", []Page{homePage, jsonPage}, loc); err != nil {
t.Fatalf("Parse with matching counts: %v", err)
}
mismatch := strings.Replace(homeOverdueFixture, "1 overdue learning task", "6 overdue learning tasks", 1)
if _, err := Parse("Harry", []Page{{Slug: PageHome, HTML: []byte(mismatch)}, jsonPage}, loc); err == nil {
t.Fatal("Parse accepted a task list that disagrees with the home page (FR-12)")
}
blank := `<html><body><div data-testid="feed-toolbar"></div></body></html>`
if _, err := Parse("Harry", []Page{{Slug: PageHome, HTML: []byte(blank)}, jsonPage}, loc); err == nil {
t.Fatal("Parse accepted a home page with no record of the child (FR-12)")
}
}