Testing against a local instance turned up four things the server was
getting wrong, all of them invisible against the live account because the
data that exposes them had never been produced there.
`GET /lessons/{id}/tasks` returns a bare array, not the `{data,total}`
envelope every sibling endpoint uses, so `.data` was undefined and a
topic's tasks silently vanished. Its items also carry no id at all —
`LessonLinkedTaskResponse` has no id property — which leaves a
topic-attached task unidentifiable: it is not a task element on the
course page, and once past due it is in neither task list. So its
submission, and its grade, could not be reached by any route. That is 18
of 60 tasks on the real account, now reachable: the ids come off the
legacy topic page, where each task is linked as `/homework/{id}`.
The types said `id: string` and `status: TaskStatus` on something that
has neither, which is what let this stay quiet; `LessonLinkedTask` and
`ResolvedTask` now say what is actually there.
Collaborative text editor elements come back with `content: {}`, and the
tool said their contents were unavailable. They are available: the
content-element endpoint returns the pad url *and* an Etherpad session
cookie, and the pad exports itself as text to whoever holds it. No API
key needed. Pads are now shown by get_board and indexed for search.
The store's file digest covered id and size on the grounds that file
records are immutable. `PATCH /file/rename/{id}` renames one in place,
so a rename was reported as nothing at all.
Finally, get_board reported an unpublished board as "no permission",
which sends the reader hunting for an access problem that is not there.
smoke gains checks for topic tasks and for pads, and no longer assumes a
populated index or a search term that happens to match. 39/39 live-only
and 41/41 index-backed, against both the live instance and a local one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
3.3 KiB
TypeScript
85 lines
3.3 KiB
TypeScript
import type { Config } from '../config.ts';
|
|
import { decodeEntities } from './text.ts';
|
|
import type { LessonLinkedTask } from './types.ts';
|
|
|
|
/**
|
|
* Recovers the ids of tasks that hang off a topic ("Thema").
|
|
*
|
|
* `GET /api/v3/lessons/{id}/tasks` returns the topic's tasks — with their name,
|
|
* description and dates, but **no id**: `LessonLinkedTaskResponse` has no id
|
|
* field at all, by design rather than by omission. The course page is no help
|
|
* either, because a topic-attached task is not a task element there; the topic
|
|
* reports only `numberOfPublishedTasks`.
|
|
*
|
|
* The consequence is that those tasks were unreachable: absent from both task
|
|
* lists once past due (open excludes them, finished only holds what the student
|
|
* ticked off), absent from the course page, and unidentifiable from the topic.
|
|
* Their submissions, and so their grades, could not be read at all. On the
|
|
* account this was written for that is 18 of 60 tasks.
|
|
*
|
|
* The legacy topic page links each task as `/homework/{id}`, so it carries the
|
|
* mapping the API withholds. Like the homework-page scrape this authenticates
|
|
* by `jwt` **cookie**, hangs off an accessibility attribute rather than
|
|
* presentation markup, and degrades to an empty list — a markup change costs
|
|
* the ids again, never an error.
|
|
*/
|
|
|
|
export interface LessonTaskLink {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export async function fetchLessonTaskLinks(
|
|
config: Config,
|
|
courseId: string,
|
|
lessonId: string,
|
|
): Promise<LessonTaskLink[]> {
|
|
const url = `${config.baseUrl}/courses/${encodeURIComponent(courseId)}/topics/${encodeURIComponent(lessonId)}`;
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
|
|
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
|
redirect: 'follow',
|
|
});
|
|
// Redirected away means the cookie was not accepted; there is nothing to
|
|
// parse and nothing worth raising.
|
|
if (!response.ok || !new URL(response.url).hostname.endsWith(new URL(config.baseUrl).hostname)) {
|
|
return [];
|
|
}
|
|
return parseLessonTaskLinks(await response.text());
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Exported for testing: the parsing is pure and deserves fixtures, not a network. */
|
|
export function parseLessonTaskLinks(html: string): LessonTaskLink[] {
|
|
// `aria-label="Details der Aufgabe: 'name'"` exists for screen readers, which
|
|
// makes it far steadier than the surrounding layout.
|
|
const pattern = /<a href="\/homework\/([0-9a-f]{24})"[^>]*aria-label="[^"']*'([^']*)'/g;
|
|
const found: LessonTaskLink[] = [];
|
|
const seen = new Set<string>();
|
|
for (const match of html.matchAll(pattern)) {
|
|
const id = match[1] ?? '';
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
found.push({ id, name: decodeEntities(match[2] ?? '').trim() });
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Pairs the API's id-less task bodies with the ids scraped from the topic page.
|
|
*
|
|
* Matching is by name, which is what both sides agree on. A name the page does
|
|
* not account for yields a task without an id: still worth reporting (it is
|
|
* visible to the user), just not something the id-taking tools can open.
|
|
*/
|
|
export function withScrapedIds(tasks: LessonLinkedTask[], links: LessonTaskLink[]): LessonLinkedTask[] {
|
|
const byName = new Map(links.map((link) => [link.name, link.id]));
|
|
return tasks.map((task) => {
|
|
const id = task.id ?? byName.get(task.name?.trim() ?? '');
|
|
return id ? { ...task, id } : task;
|
|
});
|
|
}
|