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:
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user