Rollen neu geschnitten, Seitenzahl korrigiert, frühere Konten

Der Forums-Abgleich las von 25 Archivseiten nur zwei. WoltLab verlinkt in der
Blätterleiste nur die Nachbarseiten und führt die Gesamtzahl in einem eigenen
Element; ausgewertet wurden aber die Links. Alles jenseits von Seite zwei war
damit unsichtbar - etwa ein Antrag auf Seite vier, der deshalb nie ankam.

Die Rollen verlaufen jetzt zwischen erfassen und gewähren statt zwischen
Alltag und Verwaltung: Editoren erfassen Käufe und führen Datei-Import,
GExtension- und Forums-Abgleich samt Entscheidung über Anträge durch. Kulanz
zu vergeben bleibt Administratoren, ebenso das Auflösen unklarer Pakete, weil
dabei Kulanz entstehen kann.

Dazu lassen sich frühere Steam- und Forumskonten am Spieler hinterlegen.
Abgleich und Import prüfen sie mit, sodass ein Kontowechsel die Zuordnung
nicht abreißen lässt. Steam-Kennungen werden dabei auf dieselbe Form gebracht
wie die Hauptangabe, und eine Kennung kann nicht zwei Spielern gehören.
This commit is contained in:
2026-09-18 20:07:56 +02:00
parent 3344592c6e
commit 6c50d32d2b
24 changed files with 639 additions and 62 deletions
@@ -0,0 +1,23 @@
-- Frühere Steam- und Forumskonten eines Spielers.
DO $$ BEGIN
CREATE TYPE "AliasType" AS ENUM ('STEAM_ID', 'FORUM_NAME');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS "PlayerAlias" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"type" "AliasType" NOT NULL,
"value" TEXT NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlayerAlias_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "PlayerAlias_type_value_key" ON "PlayerAlias"("type", "value");
CREATE INDEX IF NOT EXISTS "PlayerAlias_playerId_idx" ON "PlayerAlias"("playerId");
DO $$ BEGIN
ALTER TABLE "PlayerAlias" ADD CONSTRAINT "PlayerAlias_playerId_fkey"
FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+23
View File
@@ -61,6 +61,11 @@ enum ForumClassification {
IGNORE
}
enum AliasType {
STEAM_ID
FORUM_NAME
}
enum ImportStatus {
PENDING
COMMITTED
@@ -121,6 +126,7 @@ model Player {
orders ImportedOrder[]
unmatched UnmatchedPackage[]
forumThreads ForumThread[]
aliases PlayerAlias[]
@@index([displayName])
}
@@ -361,3 +367,20 @@ model ForumThreadBoard {
@@index([threadId])
}
/// Frühere Steam- oder Forumskonten eines Spielers. Wer den Account wechselt,
/// soll seine Ansprüche behalten - Abgleich und Import prüfen deshalb neben
/// den Hauptangaben auch diese Einträge.
model PlayerAlias {
id Int @id @default(autoincrement())
playerId Int
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
type AliasType
value String
note String?
createdAt DateTime @default(now())
/// Dieselbe Kennung darf nicht zwei Spielern gehören.
@@unique([type, value])
@@index([playerId])
}
+11 -1
View File
@@ -89,8 +89,18 @@ export function parseBoard(html: string): ForumThread[] {
return threads;
}
/** Höchste Seitenzahl der Blätterleiste, mindestens 1. */
/**
* Anzahl der Seiten eines Boards.
*
* WoltLab führt die Gesamtzahl in einem eigenen Element, nicht in den Links:
* die Blätterleiste zeigt nur die Nachbarseiten. Wer allein die pageNo-Links
* auswertet, hält ein Board mit 25 Seiten für zweiseitig.
*/
export function parsePageCount(html: string): number {
const element = /<woltlab-core-pagination[^>]*\scount="(\d+)"/.exec(html);
if (element) return Math.max(1, Number(element[1]));
// Ältere Fassungen ohne das Element: wenigstens die Links auswerten.
const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1]));
return numbers.length > 0 ? Math.max(1, ...numbers) : 1;
}
+3 -1
View File
@@ -30,7 +30,9 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
},
);
app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => {
// Kulanz ist eine Entscheidung des Teams, kein erfasster Kauf - deshalb
// bleibt sie Administratoren vorbehalten.
app.post('/api/players/:id/credits/kulanz', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookKulanz(id, contextOf(request), { note: body.note });
+5 -5
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ForumBoard, ForumClassification } from '@prisma/client';
import { requireAdmin, requireAuth } from '../auth/guard.js';
import { requireAuth, requireEditor } from '../auth/guard.js';
import {
assignPlayer,
classifyThread,
@@ -13,7 +13,7 @@ import {
import { contextOf, idParam, parseWith } from './helpers.js';
export async function forumRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/forum/scan', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/scan', { preHandler: requireEditor }, async (request) => {
return scanForum(contextOf(request));
});
@@ -40,13 +40,13 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
return { items: await listBoardChangeNotices() };
});
app.post('/api/forum/threads/:id/dismiss', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/threads/:id/dismiss', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await dismissBoardChangeNotice(id, contextOf(request));
return { ok: true };
});
app.post('/api/forum/threads/:id/player', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/threads/:id/player', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ playerId: z.number().int().positive() }),
@@ -56,7 +56,7 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
return { ok: true };
});
app.post('/api/forum/threads/:id/classify', { preHandler: requireAdmin }, async (request) => {
app.post('/api/forum/threads/:id/classify', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
+7 -7
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from '../auth/guard.js';
import { requireEditor } from '../auth/guard.js';
import { badRequest } from '../errors.js';
import {
applyMapping,
@@ -24,7 +24,7 @@ const mappingBody = z.object({
export async function importRoutes(app: FastifyInstance): Promise<void> {
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
app.post('/api/imports', { preHandler: requireEditor }, async (request, reply) => {
const file = await request.file();
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
@@ -41,16 +41,16 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
};
});
app.get('/api/imports', { preHandler: requireAdmin }, async () => {
app.get('/api/imports', { preHandler: requireEditor }, async () => {
return { items: await listImports() };
});
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
app.get('/api/imports/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return getImport(id);
});
app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => {
app.post('/api/imports/:id/mapping', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(mappingBody, request.body);
const summary = await applyMapping(id, body, contextOf(request));
@@ -58,12 +58,12 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
return { summary, import: record };
});
app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => {
app.post('/api/imports/:id/commit', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return commitImport(id, contextOf(request));
});
app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => {
app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request));
return { ok: true };
+26
View File
@@ -1,12 +1,15 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind } from '@prisma/client';
import { AliasType } from '@prisma/client';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { listLedger } from '../services/credits.js';
import {
addAlias,
createPlayer,
deletePlayer,
getPlayerDetail,
removeAlias,
searchPlayers,
updatePlayer,
} from '../services/players.js';
@@ -63,6 +66,29 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
return updatePlayer(id, body, contextOf(request));
});
// Frühere Steam- und Forumskonten eines Spielers.
app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
type: z.nativeEnum(AliasType),
value: z.string().trim().min(1).max(120),
note: z.string().trim().max(200).optional(),
}),
request.body,
);
const created = await addAlias(id, body.type, body.value, body.note, contextOf(request));
reply.code(201);
return created;
});
app.delete('/api/players/aliases/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await removeAlias(id, contextOf(request));
return { ok: true };
});
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
await deletePlayer(id, contextOf(request));
+3 -3
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin, requireAuth } from '../auth/guard.js';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { notFound } from '../errors.js';
import { prisma } from '../db.js';
import {
@@ -18,7 +18,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
* Profile auf Vorrat einsammeln.
*/
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
app.post('/api/scrape/run', { preHandler: requireEditor }, async (request) => {
const body = parseWith(
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
request.body ?? {},
@@ -33,7 +33,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
});
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */
app.post('/api/scrape/player/:id', { preHandler: requireAdmin }, async (request) => {
app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const player = await prisma.player.findUnique({
+25 -7
View File
@@ -14,7 +14,9 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
];
const MIN_REQUEST_GAP_MS = 1000;
const MAX_PAGES_PER_BOARD = 20;
// Das Archiv reicht Jahre zurück; bei einer Sekunde Abstand dauert ein voller
// Lauf entsprechend. Die Grenze ist eine Notbremse, keine Sparmaßnahme.
const MAX_PAGES_PER_BOARD = 60;
let lastRequestAt = 0;
async function throttle(): Promise<void> {
@@ -87,12 +89,25 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
failedBoards: [],
};
// Forumsnamen einmal laden statt je Thread zu suchen.
const players = await prisma.player.findMany({
where: { forumName: { not: null } },
select: { id: true, forumName: true },
});
const byForumName = new Map(players.map((p) => [(p.forumName as string).toLowerCase(), p.id]));
// Forumsnamen einmal laden statt je Thread zu suchen - die aktuellen und
// frühere Konten, damit ein Namenswechsel die Zuordnung nicht abreißen lässt.
const [players, aliases] = await Promise.all([
prisma.player.findMany({
where: { forumName: { not: null } },
select: { id: true, forumName: true },
}),
prisma.playerAlias.findMany({
where: { type: 'FORUM_NAME' },
select: { playerId: true, value: true },
}),
]);
const byForumName = new Map<string, number>();
for (const alias of aliases) byForumName.set(alias.value.toLowerCase(), alias.playerId);
// Die Hauptangabe zuletzt, damit sie einen gleichlautenden Alias übersticht.
for (const player of players) {
byForumName.set((player.forumName as string).toLowerCase(), player.id);
}
for (const { board, path } of BOARDS) {
let threads: ForumThread[] = [];
@@ -310,6 +325,9 @@ export async function classifyThread(
let characterId: number | null = null;
// Eine Entscheidung kann Kulanz verbrauchen. Das Vergeben von Kulanz ist
// Admins vorbehalten, das Verbrauchen nicht - hier wird also nur gebucht,
// was der Spieler bereits hat.
if (input.classification === ForumClassification.REACTIVATION) {
if (input.characterId === undefined) {
throw badRequest('NO_CHARACTER', 'Bitte angeben, welcher CC zurückkehrt');
+18 -7
View File
@@ -238,14 +238,25 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
})),
];
const players = await prisma.player.findMany({
where: {
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
},
select: { id: true, steamId: true, displayName: true },
});
const [players, aliases] = await Promise.all([
prisma.player.findMany({
where: {
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
},
select: { id: true, steamId: true, displayName: true },
}),
// Frühere Steam-Konten zählen mit.
prisma.playerAlias.findMany({
where: { type: 'STEAM_ID', value: { in: playerKeys } },
select: { playerId: true, value: true },
}),
]);
const bySteamId = new Map(players.filter((p) => p.steamId).map((p) => [p.steamId as string, p.id]));
const bySteamId = new Map<string, number>();
for (const alias of aliases) bySteamId.set(alias.value, alias.playerId);
for (const player of players) {
if (player.steamId) bySteamId.set(player.steamId, player.id);
}
const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
const orderIds = mapping.orderId
+80 -1
View File
@@ -4,6 +4,7 @@ import { conflict, notFound } from '../errors.js';
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
import { listCharacters } from './characters.js';
import { recordAudit, type ActionContext } from './audit.js';
import { AliasType } from '@prisma/client';
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
export interface PlayerInput {
@@ -67,7 +68,7 @@ export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler');
const [balances, characters, ledger, unmatched] = await Promise.all([
const [balances, characters, ledger, unmatched, aliases] = await Promise.all([
getBalances(playerId),
listCharacters(playerId),
listLedger({ playerId, take: 200 }),
@@ -76,6 +77,10 @@ export async function getPlayerDetail(playerId: number) {
orderBy: { scrapedAt: 'desc' },
include: { player: { select: { id: true, displayName: true } } },
}),
prisma.playerAlias.findMany({
where: { playerId },
orderBy: [{ type: 'asc' }, { value: 'asc' }],
}),
]);
return {
@@ -85,6 +90,7 @@ export async function getPlayerDetail(playerId: number) {
ledger: ledger.items,
ledgerTotal: ledger.total,
unmatched,
aliases,
};
}
@@ -219,3 +225,76 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
});
});
}
/**
* Trägt ein früheres Konto nach. Steam-Kennungen werden dabei auf dieselbe
* Form gebracht wie die Hauptangabe, sonst greift der Abgleich nicht.
*/
export async function addAlias(
playerId: number,
type: AliasType,
rawValue: string,
note: string | undefined,
ctx: ActionContext,
) {
const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler');
let value = rawValue.trim();
if (type === AliasType.STEAM_ID) {
const normalized = normalizeSteamId(value);
if (!normalized) {
throw conflict('INVALID_STEAMID', `"${value}" sieht nach keiner SteamID aus`);
}
value = normalized;
if (value === player.steamId) {
throw conflict('IS_PRIMARY', 'Das ist bereits die hinterlegte SteamID dieses Spielers');
}
} else if (value.toLowerCase() === player.forumName?.toLowerCase()) {
throw conflict('IS_PRIMARY', 'Das ist bereits der hinterlegte Forum-Name dieses Spielers');
}
if (value.length === 0) throw conflict('EMPTY', 'Der Wert darf nicht leer sein');
const vorhanden = await prisma.playerAlias.findUnique({
where: { type_value: { type, value } },
include: { player: { select: { displayName: true } } },
});
if (vorhanden) {
throw conflict(
'ALIAS_TAKEN',
vorhanden.playerId === playerId
? 'Dieses Konto ist hier schon eingetragen'
: `Dieses Konto gehört bereits zu "${vorhanden.player.displayName}"`,
);
}
const created = await prisma.playerAlias.create({
data: { playerId, type, value, note: note?.trim() || null },
});
await recordAudit(prisma, ctx, {
action: 'player.alias_added',
targetType: 'player',
targetId: playerId,
details: { type, value, note: note ?? null },
});
return created;
}
export async function removeAlias(aliasId: number, ctx: ActionContext): Promise<void> {
const alias = await prisma.playerAlias.findUnique({ where: { id: aliasId } });
if (!alias) throw notFound('Eintrag');
await prisma.playerAlias.delete({ where: { id: aliasId } });
await recordAudit(prisma, ctx, {
action: 'player.alias_removed',
targetType: 'player',
targetId: alias.playerId,
details: { type: alias.type, value: alias.value },
});
}
+28 -7
View File
@@ -10,15 +10,36 @@ import {
import { PLANS } from './imports.js';
import { recordAudit, type ActionContext } from './audit.js';
/** SteamIDs, die im CC-Manager bereits erfasst sind. */
/**
* Alle Steam-Kennungen, die im CC-Manager erfasst sind - die aktuelle je
* Spieler und frühere Konten. Wer den Account gewechselt hat, hat seine
* älteren Käufe unter der alten Kennung stehen.
*/
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
const players = await prisma.player.findMany({
where: { steamId: { not: null } },
select: { id: true, steamId: true },
orderBy: { id: 'asc' },
});
const [players, aliases] = await Promise.all([
prisma.player.findMany({
where: { steamId: { not: null } },
select: { id: true, steamId: true },
orderBy: { id: 'asc' },
}),
prisma.playerAlias.findMany({
where: { type: 'STEAM_ID' },
select: { playerId: true, value: true },
}),
]);
return players.map((player) => ({ playerId: player.id, steamId: player.steamId as string }));
const alle = [
...players.map((player) => ({ playerId: player.id, steamId: player.steamId as string })),
...aliases.map((alias) => ({ playerId: alias.playerId, steamId: alias.value })),
];
// Dieselbe Kennung nicht zweimal abrufen.
const gesehen = new Set<string>();
return alle.filter((eintrag) => {
if (gesehen.has(eintrag.steamId)) return false;
gesehen.add(eintrag.steamId);
return true;
});
}
export interface ScrapeSummary {
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Players = typeof import('../src/services/players.js');
type Scraper = typeof import('../src/services/scraper.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let players: Players;
let scraper: Scraper;
let db: Db;
let ctx: { actorId: number; actorName: string };
before(async () => {
database = await startTestDatabase(54335);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
players = await import('../src/services/players.js');
scraper = await import('../src/services/scraper.js');
const user = await db.prisma.user.create({
data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Frühere Konten', () => {
it('bringt eine nachgetragene SteamID auf dieselbe Form wie die Hauptangabe', async () => {
const player = await db.prisma.player.create({
data: { displayName: 'Umzügler', steamId: '76561198000001111' },
});
// In der Legacy-Schreibweise eingegeben, gespeichert wird die 64-Bit-Form.
await players.addAlias(player.id, 'STEAM_ID', 'STEAM_0:1:19867635', 'Alter Account', ctx);
const alias = await db.prisma.playerAlias.findFirstOrThrow({ where: { playerId: player.id } });
assert.equal(alias.value, '76561198000000999');
});
it('klappert beim Abgleich auch die früheren Konten ab', async () => {
const ids = await scraper.knownSteamIds();
const fuerUmzuegler = ids.filter((e) => e.steamId.startsWith('765611980000'));
assert.ok(
fuerUmzuegler.some((e) => e.steamId === '76561198000001111'),
'die aktuelle Kennung',
);
assert.ok(
fuerUmzuegler.some((e) => e.steamId === '76561198000000999'),
'und die frühere',
);
});
it('nimmt dieselbe Kennung nicht zweimal in den Lauf', async () => {
const ids = await scraper.knownSteamIds();
const eindeutig = new Set(ids.map((e) => e.steamId));
assert.equal(ids.length, eindeutig.size);
});
it('lässt ein Konto nicht zwei Spielern gehören', async () => {
const anderer = await db.prisma.player.create({ data: { displayName: 'Fremder' } });
await assert.rejects(
() => players.addAlias(anderer.id, 'STEAM_ID', '76561198000000999', undefined, ctx),
(error: Error & { code?: string }) => error.code === 'ALIAS_TAKEN',
);
});
it('weist die eigene Hauptangabe als Alias ab', async () => {
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
await assert.rejects(
() => players.addAlias(player.id, 'STEAM_ID', '76561198000001111', undefined, ctx),
(error: Error & { code?: string }) => error.code === 'IS_PRIMARY',
);
});
it('weist Unsinn als SteamID ab', async () => {
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
await assert.rejects(
() => players.addAlias(player.id, 'STEAM_ID', 'kein-account', undefined, ctx),
(error: Error & { code?: string }) => error.code === 'INVALID_STEAMID',
);
});
it('nimmt Forum-Namen ohne Umrechnung', async () => {
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
const alias = await players.addAlias(player.id, 'FORUM_NAME', 'AlterName2019', undefined, ctx);
assert.equal(alias.value, 'AlterName2019');
});
});
+1
View File
@@ -70,6 +70,7 @@
</li>
</ol>
<woltlab-core-pagination page="1" count="25" url="https://forum.example.org/index.php?board/66-custom-characters/"></woltlab-core-pagination>
<nav class="pagination"><ul>
<li><a href="index.php?board/66-custom-characters/&amp;pageNo=2" class="pageNavigationLink">2</a></li>
<li><a href="index.php?board/66-custom-characters/&amp;pageNo=3" class="pageNavigationLink">3</a></li>
+8 -2
View File
@@ -40,8 +40,14 @@ describe('Board-Übersicht lesen', () => {
assert.equal(threads.some((t) => t.threadId === 9999), false);
});
it('liest die Seitenzahl aus der Blätterleiste', () => {
assert.equal(parsePageCount(BOARD), 3);
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);
});