Zeitplan in der Oberflaeche einstellbar

Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen
sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung ->
Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim
Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab
wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht
gelaufen wird.

Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und
daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet.
Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu,
die der Community gehoeren.

Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen
Werte, sie stehen in der Migration.

SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
This commit is contained in:
2026-09-21 13:25:56 +02:00
parent db37d3388a
commit 6094e4eaef
14 changed files with 658 additions and 168 deletions
@@ -0,0 +1,22 @@
-- Der Zeitplan wird einstellbar. Bisher standen Takt und Schalter in der
-- Umgebung und liessen sich nur mit einem Neustart aendern.
CREATE TABLE IF NOT EXISTS "ScheduleSetting" (
"name" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"intervalMinutes" INTEGER NOT NULL DEFAULT 120,
"idleIntervalMinutes" INTEGER,
"idleAfterDays" INTEGER,
"onlyAfterHour" INTEGER,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ScheduleSetting_pkey" PRIMARY KEY ("name")
);
-- Die bisherigen Werte als Ausgangspunkt: Kaeufe alle zwei Stunden, ruhende
-- Konten alle sechs, ruhend ab drei Jahren; das Forum einmal taeglich,
-- fruehestens ab vier Uhr.
INSERT INTO "ScheduleSetting"
("name", "enabled", "intervalMinutes", "idleIntervalMinutes", "idleAfterDays", "onlyAfterHour")
VALUES
('gextension', true, 120, 360, 1095, NULL),
('forum', true, 1440, NULL, NULL, 4)
ON CONFLICT ("name") DO NOTHING;
+23
View File
@@ -506,3 +506,26 @@ model CharacterLink {
@@index([characterId])
}
/// Was der Zeitplan tun soll. Steht in der Datenbank statt in der Umgebung,
/// damit sich der Takt ändern lässt, ohne den Dienst neu zu starten.
model ScheduleSetting {
/// "gextension" oder "forum".
name String @id
enabled Boolean @default(true)
/// Abstand zwischen zwei Läufen, in Minuten.
intervalMinutes Int @default(120)
/// Nur beim Käufe-Abgleich: der seltenere Takt für Konten, bei denen lange
/// nichts gekauft wurde, und ab wann ein Konto als ruhend gilt.
idleIntervalMinutes Int?
idleAfterDays Int?
/// Nur beim Forumslauf: frühestens ab dieser Stunde. Damit lässt sich ein
/// täglicher Lauf in die Nacht legen, statt ihn irgendwann am Tag
/// loszuschicken.
onlyAfterHour Int?
updatedAt DateTime @updatedAt
}
-9
View File
@@ -35,15 +35,6 @@ const schema = z.object({
// WoltLab (Burning Board) zugeschnitten.
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
// Wiederkehrende Abgleiche: Käufe alle zwei Stunden (ruhende Konten alle
// sechs), das Forum einmal nachts. Abschaltbar, etwa für eine zweite
// Instanz, die nicht mitlaufen soll.
SCHEDULE_ENABLED: z
.enum(['true', 'false'])
.default('true')
.transform((value) => value === 'true'),
FORUM_SCAN_HOUR: z.coerce.number().int().min(0).max(23).default(4),
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
+26 -7
View File
@@ -267,14 +267,33 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
// --- Audit-Log ---
/** Stand der wiederkehrenden Abgleiche. */
/** Einstellungen und Stand der wiederkehrenden Abgleiche. */
app.get('/api/admin/schedule', async () => {
const { listRuns } = await import('../services/schedule.js');
return {
enabled: env.SCHEDULE_ENABLED,
forumHour: env.FORUM_SCAN_HOUR,
runs: await listRuns(),
};
const { listRuns, listSettings } = await import('../services/schedule.js');
const [settings, runs] = await Promise.all([listSettings(), listRuns()]);
return { settings, runs };
});
app.patch('/api/admin/schedule/:name', async (request) => {
const { updateSetting, LAEUFE } = await import('../services/schedule.js');
const params = parseWith(
z.object({ name: z.enum(['gextension', 'forum']) }),
request.params,
);
const body = parseWith(
z.object({
enabled: z.boolean().optional(),
intervalMinutes: z.number().int().optional(),
idleIntervalMinutes: z.number().int().nullable().optional(),
idleAfterDays: z.number().int().nullable().optional(),
onlyAfterHour: z.number().int().nullable().optional(),
}),
request.body,
);
if (!LAEUFE.includes(params.name)) throw notFound('Lauf');
return updateSetting(params.name, body, contextOf(request));
});
app.get('/api/admin/audit', async (request) => {
+183 -48
View File
@@ -1,9 +1,9 @@
import { CreditKind } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest } from '../errors.js';
import { knownSteamIds, scrapePlayers } from './scraper.js';
import { scanForum } from './forum.js';
import type { ActionContext } from './audit.js';
import { recordAudit, type ActionContext } from './audit.js';
/**
* Wiederkehrende Läufe.
@@ -14,24 +14,52 @@ import type { ActionContext } from './audit.js';
* bearbeitet zu werden - und weil ein voller Durchlauf gut zwei Dutzend
* Seiten liest.
*
* Der Zeitplan steht hier, der Stand in der Datenbank. Nach einem Neustart
* wird deshalb nicht alles erneut angestoßen.
* Takt und Schalter stehen in der Datenbank, nicht in der Umgebung: so lässt
* sich beides im Betrieb ändern, ohne den Dienst neu zu starten. Der Stand
* der Läufe steht ebenfalls dort, damit ein Neustart nicht alles erneut
* anstösst.
*/
/// Wie oft überhaupt nachgesehen wird, ob etwas fällig ist.
const TICK_MS = 5 * 60 * 1000;
const TICK_MS = 60 * 1000;
/// Wer in letzter Zeit gekauft hat, wird häufiger geprüft.
const AKTIVE_PRUEFUNG_MS = 2 * 60 * 60 * 1000;
/// Wessen letzter Kauf lange zurückliegt, seltener - das spart die meisten
/// Anfragen, denn das sind mit der Zeit die meisten Spieler.
const RUHENDE_PRUEFUNG_MS = 6 * 60 * 60 * 1000;
/// Ab wann jemand als ruhend gilt.
const RUHEND_AB_MS = 3 * 365 * 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
const TAG_MS = 24 * 60 * MINUTE_MS;
/// Die beiden Läufe, die es gibt.
export const LAEUFE = ['gextension', 'forum'] as const;
export type LaufName = (typeof LAEUFE)[number];
/// Unter diesem Namen läuft, was der Zeitplan anstößt.
const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' };
/// Fällt ein Eintrag, greifen diese Werte - dieselben, mit denen der
/// Zeitplan ursprünglich fest verdrahtet war.
const VORGABEN: Record<LaufName, ScheduleSettingInput> = {
gextension: {
enabled: true,
intervalMinutes: 120,
idleIntervalMinutes: 360,
idleAfterDays: 1095,
onlyAfterHour: null,
},
forum: {
enabled: true,
intervalMinutes: 1440,
idleIntervalMinutes: null,
idleAfterDays: null,
onlyAfterHour: 4,
},
};
export interface ScheduleSettingInput {
enabled: boolean;
intervalMinutes: number;
idleIntervalMinutes: number | null;
idleAfterDays: number | null;
onlyAfterHour: number | null;
}
export interface RunState {
name: string;
lastRunAt: Date | null;
@@ -39,6 +67,91 @@ export interface RunState {
lastNote: string | null;
}
export async function getSetting(name: LaufName): Promise<ScheduleSettingInput> {
const eintrag = await prisma.scheduleSetting.findUnique({ where: { name } });
if (!eintrag) return VORGABEN[name];
return {
enabled: eintrag.enabled,
intervalMinutes: eintrag.intervalMinutes,
idleIntervalMinutes: eintrag.idleIntervalMinutes,
idleAfterDays: eintrag.idleAfterDays,
onlyAfterHour: eintrag.onlyAfterHour,
};
}
export async function listSettings(): Promise<(ScheduleSettingInput & { name: LaufName })[]> {
return Promise.all(
LAEUFE.map(async (name) => ({ name, ...(await getSetting(name)) })),
);
}
/**
* Ändert die Einstellungen eines Laufs.
*
* Die Grenzen sind bewusst eng: unter einer Viertelstunde wäre der Abgleich
* eine Belästigung für einen fremden Server, über einer Woche liefe er
* praktisch nicht mehr.
*/
export async function updateSetting(
name: LaufName,
input: Partial<ScheduleSettingInput>,
ctx: ActionContext,
): Promise<ScheduleSettingInput> {
const vorher = await getSetting(name);
const pruefe = (wert: number | null | undefined, feld: string): number | null | undefined => {
if (wert === undefined || wert === null) return wert;
if (!Number.isInteger(wert) || wert < 15 || wert > 10080) {
throw badRequest(
'INVALID_INTERVAL',
`${feld}: zwischen 15 Minuten und einer Woche (10080 Minuten)`,
);
}
return wert;
};
const intervalMinutes = pruefe(input.intervalMinutes, 'Abstand') ?? vorher.intervalMinutes;
const idleIntervalMinutes =
input.idleIntervalMinutes === undefined
? vorher.idleIntervalMinutes
: (pruefe(input.idleIntervalMinutes, 'Abstand für ruhende Konten') ?? null);
if (input.idleAfterDays !== undefined && input.idleAfterDays !== null) {
if (!Number.isInteger(input.idleAfterDays) || input.idleAfterDays < 1) {
throw badRequest('INVALID_DAYS', 'Ruhend ab: mindestens ein Tag');
}
}
if (input.onlyAfterHour !== undefined && input.onlyAfterHour !== null) {
if (!Number.isInteger(input.onlyAfterHour) || input.onlyAfterHour < 0 || input.onlyAfterHour > 23) {
throw badRequest('INVALID_HOUR', 'Frühestens ab: eine Stunde zwischen 0 und 23');
}
}
const daten = {
enabled: input.enabled ?? vorher.enabled,
intervalMinutes,
idleIntervalMinutes,
idleAfterDays: input.idleAfterDays === undefined ? vorher.idleAfterDays : input.idleAfterDays,
onlyAfterHour: input.onlyAfterHour === undefined ? vorher.onlyAfterHour : input.onlyAfterHour,
};
await prisma.scheduleSetting.upsert({
where: { name },
create: { name, ...daten },
update: daten,
});
await recordAudit(prisma, ctx, {
action: 'schedule.updated',
targetType: 'schedule',
targetId: null,
details: { name, ...daten },
});
return daten;
}
async function letzterLauf(name: string): Promise<Date | null> {
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
return eintrag?.lastRunAt ?? null;
@@ -66,13 +179,21 @@ export async function listRuns(): Promise<RunState[]> {
/**
* Die Spieler, die jetzt an der Reihe sind.
*
* Wer in den letzten drei Jahren etwas gekauft hat, wird alle zwei Stunden
* geprüft. Bei allen anderen reichen sechs - wer seit Jahren nichts kauft,
* fängt selten von einer Stunde auf die andere damit an, und es sind mit
* Abstand die meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt
* der Eingabe hier.
* Wer in letzter Zeit gekauft hat, wird im normalen Takt geprüft. Bei allen
* anderen reicht der seltenere - wer seit Jahren nichts kauft, fängt selten
* von einer Stunde auf die andere damit an, und es sind mit Abstand die
* meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt der Eingabe
* hier.
*/
export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
export async function faelligeSpieler(
jetzt = new Date(),
einstellung?: ScheduleSettingInput,
): Promise<number[]> {
const regel = einstellung ?? (await getSetting('gextension'));
const aktiverTakt = regel.intervalMinutes * MINUTE_MS;
const ruhenderTakt = (regel.idleIntervalMinutes ?? regel.intervalMinutes) * MINUTE_MS;
const ruhendAb = (regel.idleAfterDays ?? 0) * TAG_MS;
const [spieler, letzteKaeufe] = await Promise.all([
prisma.player.findMany({
where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] },
@@ -91,8 +212,8 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
.filter((p) => {
const letzterKauf = kaufNach.get(p.id) ?? null;
const ruhend =
letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > RUHEND_AB_MS;
const abstand = ruhend ? RUHENDE_PRUEFUNG_MS : AKTIVE_PRUEFUNG_MS;
ruhendAb > 0 && (letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > ruhendAb);
const abstand = ruhend ? ruhenderTakt : aktiverTakt;
if (p.gextensionSyncedAt === null) return true;
return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand;
@@ -100,18 +221,45 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
.map((p) => p.id);
}
/** Ob der nächtliche Lauf für heute noch aussteht. */
export function forumLaufFaellig(letzter: Date | null, jetzt: Date, stunde: number): boolean {
if (jetzt.getHours() < stunde) return false;
/**
* Ob ein Lauf jetzt ansteht.
*
* Ohne Stundenfenster genügt der Abstand zum letzten Lauf. Mit Fenster wird
* zusätzlich verlangt, dass die Stunde erreicht ist - so lässt sich ein
* täglicher Lauf verlässlich in die Nacht legen, statt ihn mit jedem
* Neustart weiter in den Tag wandern zu lassen.
*/
export function istFaellig(
letzter: Date | null,
jetzt: Date,
regel: Pick<ScheduleSettingInput, 'intervalMinutes' | 'onlyAfterHour'>,
): boolean {
if (letzter !== null) {
const vergangen = jetzt.getTime() - letzter.getTime();
if (vergangen < regel.intervalMinutes * MINUTE_MS) return false;
}
const heuteUm = new Date(jetzt);
heuteUm.setHours(stunde, 0, 0, 0);
if (regel.onlyAfterHour === null || regel.onlyAfterHour === undefined) return true;
if (jetzt.getHours() < regel.onlyAfterHour) return false;
return letzter === null || letzter < heuteUm;
// Innerhalb desselben Tages nicht zweimal, wenn der Takt tageweise läuft.
if (letzter !== null && regel.intervalMinutes >= 1440) {
const heuteAb = new Date(jetzt);
heuteAb.setHours(regel.onlyAfterHour, 0, 0, 0);
if (letzter >= heuteAb) return false;
}
return true;
}
async function kaeufePruefen(log: Logger): Promise<void> {
const faellig = await faelligeSpieler();
interface Logger {
info: (msg: string) => void;
warn: (msg: string) => void;
debug: (msg: string) => void;
}
async function kaeufePruefen(log: Logger, regel: ScheduleSettingInput): Promise<void> {
const faellig = await faelligeSpieler(new Date(), regel);
if (faellig.length === 0) {
log.debug('Zeitplan: keine Spieler fällig');
return;
@@ -144,22 +292,11 @@ async function forumPruefen(log: Logger): Promise<void> {
await laufNotieren('forum', ergebnis.failedBoards.length > 0 ? 'FAILED' : 'OK', note);
}
interface Logger {
info: (msg: string) => void;
warn: (msg: string) => void;
debug: (msg: string) => void;
}
/**
* Startet den Zeitplan. Es läuft immer nur ein Abgleich - stockt einer, wird
* der nächste Termin übersprungen statt zwei Läufe übereinanderzulegen.
*/
export function startSchedule(log: Logger): () => void {
if (!env.SCHEDULE_ENABLED) {
log.info('Zeitplan ist abgeschaltet (SCHEDULE_ENABLED=false)');
return () => {};
}
let laeuft = false;
const tick = async (): Promise<void> => {
@@ -169,16 +306,16 @@ export function startSchedule(log: Logger): () => void {
try {
const jetzt = new Date();
const forumZuletzt = await letzterLauf('forum');
if (forumLaufFaellig(forumZuletzt, jetzt, env.FORUM_SCAN_HOUR)) {
const forumRegel = await getSetting('forum');
if (forumRegel.enabled && istFaellig(await letzterLauf('forum'), jetzt, forumRegel)) {
await forumPruefen(log);
return; // Der Forumslauf dauert; die Käufe kommen beim nächsten Takt.
}
const kaeufeZuletzt = await letzterLauf('gextension');
const abstandReicht =
kaeufeZuletzt === null || jetzt.getTime() - kaeufeZuletzt.getTime() >= AKTIVE_PRUEFUNG_MS;
if (abstandReicht) await kaeufePruefen(log);
const kaufRegel = await getSetting('gextension');
if (kaufRegel.enabled && istFaellig(await letzterLauf('gextension'), jetzt, kaufRegel)) {
await kaeufePruefen(log, kaufRegel);
}
} catch (error) {
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`);
@@ -195,9 +332,7 @@ export function startSchedule(log: Logger): () => void {
start.unref();
timer.unref();
log.info(
`Zeitplan läuft: Käufe alle 2 Stunden (ruhende Konten alle 6), Forum täglich um ${env.FORUM_SCAN_HOUR} Uhr`,
);
log.info('Zeitplan läuft; Takt und Schalter stehen unter Verwaltung → Zeitplan');
return () => {
clearTimeout(start);
-1
View File
@@ -21,7 +21,6 @@ before(async () => {
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js');
backup = await import('../src/services/backup.js');
-1
View File
@@ -17,7 +17,6 @@ before(async () => {
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js');
characters = await import('../src/services/characters.js');
+86 -15
View File
@@ -21,8 +21,6 @@ before(async () => {
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
// Der Zeitplan selbst darf im Test nicht anlaufen, nur seine Logik zählt.
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js');
schedule = await import('../src/services/schedule.js');
@@ -139,37 +137,110 @@ describe('Wer wann auf neue Pakete geprüft wird', () => {
});
});
describe('Der nächtliche Forumslauf', () => {
const nacht = 4;
describe('Wann ein Lauf ansteht', () => {
const taeglichNachts = { intervalMinutes: 1440, onlyAfterHour: 4 };
const allezweiStunden = { intervalMinutes: 120, onlyAfterHour: null };
it('läuft nicht, bevor die Stunde erreicht ist', () => {
const frueh = new Date('2026-09-21T02:30:00');
assert.equal(schedule.forumLaufFaellig(null, frueh, nacht), false);
it('wartet den eingestellten Abstand ab', () => {
const jetzt = new Date('2026-09-21T12:00:00');
const vorEinerStunde = new Date('2026-09-21T11:00:00');
const vorDreiStunden = new Date('2026-09-21T09:00:00');
assert.equal(schedule.istFaellig(vorEinerStunde, jetzt, allezweiStunden), false);
assert.equal(schedule.istFaellig(vorDreiStunden, jetzt, allezweiStunden), true);
});
it('läuft, sobald die Stunde erreicht ist und heute noch nichts lief', () => {
const morgens = new Date('2026-09-21T04:10:00');
const gestern = new Date('2026-09-20T04:05:00');
assert.equal(schedule.forumLaufFaellig(gestern, morgens, nacht), true);
it('läuft beim ersten Mal sofort', () => {
assert.equal(schedule.istFaellig(null, new Date('2026-09-21T12:00:00'), allezweiStunden), true);
});
it('hält sich beim Forum an die Stunde', () => {
const frueh = new Date('2026-09-21T02:30:00');
assert.equal(schedule.istFaellig(null, frueh, taeglichNachts), false, 'vor vier Uhr nicht');
const nachVier = new Date('2026-09-21T04:10:00');
assert.equal(schedule.istFaellig(null, nachVier, taeglichNachts), true);
});
it('läuft an einem Tag nur einmal', () => {
const morgens = new Date('2026-09-21T04:10:00');
const abends = new Date('2026-09-21T22:00:00');
assert.equal(schedule.forumLaufFaellig(morgens, abends, nacht), false);
assert.equal(schedule.istFaellig(morgens, abends, taeglichNachts), false);
});
it('holt einen ausgefallenen Lauf am selben Tag nach', () => {
// Der Dienst stand nachts still und kommt erst mittags hoch.
const mittags = new Date('2026-09-21T12:00:00');
const vorgestern = new Date('2026-09-19T04:00:00');
assert.equal(schedule.forumLaufFaellig(vorgestern, mittags, nacht), true);
assert.equal(schedule.istFaellig(vorgestern, mittags, taeglichNachts), true);
});
it('stößt nach einem Neustart nicht erneut an', () => {
it('stösst nach einem Neustart nicht erneut an', () => {
const nachStart = new Date('2026-09-21T06:00:00');
const heuteFrueh = new Date('2026-09-21T04:02:00');
assert.equal(schedule.forumLaufFaellig(heuteFrueh, nachStart, nacht), false);
assert.equal(schedule.istFaellig(heuteFrueh, nachStart, taeglichNachts), false);
});
it('ignoriert die Stunde, wenn der Takt kürzer als ein Tag ist', () => {
// Ein sechsstündlicher Lauf ab acht Uhr soll auch um vierzehn Uhr laufen.
const regel = { intervalMinutes: 360, onlyAfterHour: 8 };
const mittags = new Date('2026-09-21T14:00:00');
const morgens = new Date('2026-09-21T08:00:00');
assert.equal(schedule.istFaellig(morgens, mittags, regel), true);
});
});
describe('Einstellungen des Zeitplans', () => {
it('liefert Vorgaben, solange nichts eingestellt wurde', async () => {
const kaeufe = await schedule.getSetting('gextension');
assert.equal(kaeufe.intervalMinutes, 120);
assert.equal(kaeufe.idleIntervalMinutes, 360);
assert.equal(kaeufe.idleAfterDays, 1095);
const forum = await schedule.getSetting('forum');
assert.equal(forum.intervalMinutes, 1440);
assert.equal(forum.onlyAfterHour, 4);
});
it('nimmt eine Änderung an und wendet sie sofort an', async () => {
const ctx = { actorId: null, actorName: 'Test' };
await schedule.updateSetting('gextension', { intervalMinutes: 45, enabled: false }, ctx);
const danach = await schedule.getSetting('gextension');
assert.equal(danach.intervalMinutes, 45);
assert.equal(danach.enabled, false);
assert.equal(danach.idleIntervalMinutes, 360, 'unberührte Felder bleiben');
await schedule.updateSetting('gextension', { intervalMinutes: 120, enabled: true }, ctx);
});
it('weist unsinnige Abstände ab', async () => {
const ctx = { actorId: null, actorName: 'Test' };
for (const minuten of [0, 5, 20000]) {
await assert.rejects(
() => schedule.updateSetting('forum', { intervalMinutes: minuten }, ctx),
(error: Error & { code?: string }) => error.code === 'INVALID_INTERVAL',
`${minuten} Minuten hätten abgewiesen werden müssen`,
);
}
await assert.rejects(
() => schedule.updateSetting('forum', { onlyAfterHour: 25 }, ctx),
(error: Error & { code?: string }) => error.code === 'INVALID_HOUR',
);
});
it('richtet sich beim Fälligsein nach der Einstellung', async () => {
const ctx = { actorId: null, actorName: 'Test' };
await schedule.updateSetting('gextension', { intervalMinutes: 30 }, ctx);
const regel = await schedule.getSetting('gextension');
const jetzt = new Date('2026-09-21T12:00:00');
assert.equal(schedule.istFaellig(new Date('2026-09-21T11:45:00'), jetzt, regel), false);
assert.equal(schedule.istFaellig(new Date('2026-09-21T11:15:00'), jetzt, regel), true);
await schedule.updateSetting('gextension', { intervalMinutes: 120 }, ctx);
});
});