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