Files
CC-Manager/server/test/forum.test.ts
T
Harlekin 2b78a972bc Ersteller statt letztem Antwortendem lesen
Ein Antrag hing bei einem Spieler, der ihn weder geschrieben noch
kommentiert hatte. Die Ursache steht im HTML der Boarduebersicht: Der
Ersteller "-Daniel-" hat sein Konto geloescht und steht dort ohne
Profil-Link. Der einzige Profil-Link der Zeile gehoerte damit dem letzten
Antwortenden - und genau den nahm der Parser, weil er nach dem ersten Treffer
im ganzen Zeilenblock suchte.

Jetzt wird die Zeile vor der Spalte "letzter Beitrag" abgeschnitten und
zuerst in der Zelle gesucht, die den Ersteller fuehrt. Erst wenn dort kein
Link steht, gilt der blosse Text - so wie es bei einem geloeschten Konto der
Fall ist. Auf vier Stichprobenseiten des Archivs trifft das fuenf von achtzig
Threads; hochgerechnet liegen im Bestand also rund drei Dutzend falsch.

Die falschen Zuordnungen bleiben nicht stehen: Aendert sich der Ersteller
eines bekannten Threads, wird seine Zuordnung neu bestimmt - aber nur, wenn
die bisherige von selbst zustande kam. Wer von Hand zugeordnet hat, wusste
mehr als der Abgleich, das bleibt unangetastet. Die Abschlussmeldung nennt,
wie viele Threads dabei gewandert sind.
2026-09-21 17:06:55 +02:00

161 lines
6.2 KiB
TypeScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js';
import { isRateLimited, retryAfterMs } from '../src/lib/ratelimit.js';
const here = dirname(fileURLToPath(import.meta.url));
const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8');
describe('Board-Übersicht lesen', () => {
const threads = parseBoard(BOARD);
it('liest die Threads der Liste', () => {
assert.equal(threads.length, 7);
assert.deepEqual(
threads.map((t) => t.threadId),
[7001, 7002, 7004, 7005, 7003, 7006, 7007],
);
});
it('verwechselt den letzten Antwortenden nicht mit dem Ersteller', () => {
// Der Ersteller hat sein Konto geloescht und steht ohne Profillink da.
// Der letzte Antwortende hat eines - wer nach dem ersten Profillink in
// der Zeile greift, schreibt den Antrag dem Falschen zu.
const fremd = threads.find((t) => t.threadId === 7007);
assert.equal(fremd?.authorName, '-Verschwunden-');
assert.equal(fremd?.authorForumId, null, 'er hat kein Konto mehr');
assert.equal(fremd?.authorProfileUrl, null);
});
it('liest das Datum des Antrags, nicht das der letzten Antwort', () => {
const erster = threads.find((t) => t.threadId === 7001);
assert.equal(erster?.postedAt?.toISOString(), '2025-03-04T18:22:05.000Z');
});
it('kommt ohne Zeitangabe klar', () => {
const ohneZeit = threads.find((t) => t.threadId === 7002);
assert.equal(ohneZeit?.postedAt, null);
});
it('liest den Ersteller auch ohne Profil-Link', () => {
// Wer sein Konto geloescht hat, steht als blosser Text in der Zelle. Ohne
// Rueckfall haette der Thread keinen Autor und liesse sich niemandem
// zuordnen - er waere im Abgleich unsichtbar.
const ohneKonto = threads.find((t) => t.threadId === 7006);
assert.equal(ohneKonto?.authorName, 'Ehemaliger');
assert.equal(ohneKonto?.authorForumId, null);
});
it('nimmt den Ersteller aus der Übersicht, nicht den letzten Antwortenden', () => {
const first = threads.find((t) => t.threadId === 7001);
assert.equal(first?.authorName, 'Antragsteller');
assert.equal(first?.authorForumId, 501, 'die Forum-ID überlebt Namensänderungen');
});
it('löst Umlaut- und Anführungszeichen-Entities auf', () => {
const second = threads.find((t) => t.threadId === 7002);
assert.equal(second?.title, 'Custom Character Redesign | Alt zu Neu');
});
it('erkennt einen zurückgelassenen Verweis als solchen', () => {
const stub = threads.find((t) => t.threadId === 7003);
assert.equal(stub?.isMovedStub, true);
});
it('ignoriert Unterforen und deren letzte Beiträge', () => {
// 9999 steht im wbbBoardNode-Kasten, gehoert aber nicht in dieses Board.
assert.equal(threads.some((t) => t.threadId === 9999), false);
});
it('liest die Gesamtzahl der Seiten, nicht nur die verlinkten', () => {
// Die Blätterleiste verlinkt hier nur Seite 2 und 3, das Board hat aber 25.
// Wer die Links auswertet, liest den grössten Teil des Archivs nie.
assert.equal(parsePageCount(BOARD), 25);
});
it('fällt ohne das Element auf die Links zurück', () => {
assert.equal(parsePageCount('<a href="x&pageNo=7">7</a>'), 7);
assert.equal(parsePageCount('<html>ohne Blaetterleiste</html>'), 1);
});
it('kommt mit einer leeren Seite klar', () => {
assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []);
});
});
describe('Ankündigungen aussortieren', () => {
const threads = parseBoard(BOARD);
it('liest die Labels eines Threads', () => {
const regelwerk = threads.find((t) => t.threadId === 7004);
assert.deepEqual(regelwerk?.labels, ['Wichtig', 'Global']);
});
it('erkennt Wichtig und Information als Ausschluss', () => {
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7004)!), true);
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7005)!), true);
});
it('lässt echte Anträge durch, auch mit anderen Labels', () => {
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7001)!), false);
});
it('wertet den Titel nicht aus', () => {
// "The Duke Rang Update" war ein echter Antrag, "Regelwerk" keiner. Am Titel
// ist das nicht zu erkennen - deshalb entscheidet allein das Label.
const ohneStichwort = parseBoard(BOARD).find((t) => t.threadId === 7005);
assert.ok(ohneStichwort, 'auch ein Thread ohne CC im Titel wird gelesen');
});
});
describe('Thread-Verweis lesen', () => {
it('nimmt den vollen Link', () => {
assert.equal(
parseThreadId('https://senseigaming.de/forum/index.php?thread/8041-cc-antrag-feldagent/'),
8041,
);
});
it('nimmt auch nur den Pfad oder die blanke Nummer', () => {
assert.equal(parseThreadId('index.php?thread/7001-irgendwas/'), 7001);
assert.equal(parseThreadId('7001'), 7001);
});
it('weist Unbrauchbares ab', () => {
assert.equal(parseThreadId(''), null);
assert.equal(parseThreadId('kein link'), null);
});
});
describe('Ruecksicht auf einen bremsenden Server', () => {
it('erkennt die Abweisungen, die "spaeter nochmal" bedeuten', () => {
assert.equal(isRateLimited(429), true);
assert.equal(isRateLimited(503), true);
assert.equal(isRateLimited(404), false, 'eine fehlende Seite kommt auch spaeter nicht');
assert.equal(isRateLimited(200), false);
});
it('haelt sich an die vom Forum genannte Wartezeit', () => {
assert.equal(retryAfterMs('30', 0), 30000);
});
it('deckelt eine unverschaemt lange Angabe', () => {
assert.equal(retryAfterMs('86400', 0), 60000);
});
it('wartet ohne Angabe mit jedem Versuch laenger', () => {
assert.equal(retryAfterMs(null, 0), 5000);
assert.equal(retryAfterMs(null, 1), 10000);
assert.equal(retryAfterMs(null, 2), 15000);
});
it('ignoriert eine unbrauchbare Angabe', () => {
// WoltLab darf hier auch ein Datum schicken - dann greift die Staffelung.
assert.equal(retryAfterMs('Wed, 21 Oct 2026 07:28:00 GMT', 1), 10000);
assert.equal(retryAfterMs('-5', 0), 5000);
});
});