Files
CC-Manager/server/test/zeitplan.test.ts
T
Harlekin 6094e4eaef 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.
2026-09-21 13:25:56 +02:00

279 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Schedule = typeof import('../src/services/schedule.js');
type Scraper = typeof import('../src/services/scraper.js');
type Forum = typeof import('../src/services/forum.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let schedule: Schedule;
let scraper: Scraper;
let forum: Forum;
let db: Db;
const JETZT = new Date('2026-09-21T12:00:00Z');
const TAG = 24 * 60 * 60 * 1000;
before(async () => {
database = await startTestDatabase(54338);
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');
schedule = await import('../src/services/schedule.js');
scraper = await import('../src/services/scraper.js');
forum = await import('../src/services/forum.js');
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
/**
* Ein Spieler mit Steam-Konto, letztem Kauf vor `kaufVorTagen` und letztem
* Abgleich vor `abgleichVorStunden`.
*/
async function spieler(
name: string,
steamId: string,
kaufVorTagen: number | null,
abgleichVorStunden: number | null,
) {
const angelegt = await db.prisma.player.create({
data: {
displayName: name,
steamId,
gextensionSyncedAt:
abgleichVorStunden === null
? null
: new Date(JETZT.getTime() - abgleichVorStunden * 60 * 60 * 1000),
},
});
if (kaufVorTagen !== null) {
await db.prisma.creditLedger.create({
data: {
playerId: angelegt.id,
kind: 'CC',
amount: 1,
source: 'CC_PACKAGE',
occurredAt: new Date(JETZT.getTime() - kaufVorTagen * TAG),
},
});
}
return angelegt.id;
}
describe('Wer wann auf neue Pakete geprüft wird', () => {
let frisch = 0;
let frischGeradeGeprueft = 0;
let ruhend = 0;
let ruhendLangeHer = 0;
let nochNieGeprueft = 0;
before(async () => {
// Kauf vor einem Monat, Abgleich vor drei Stunden: fällig (Takt zwei Stunden).
frisch = await spieler('Frisch', '76561198000020001', 30, 3);
// Ebenso frisch, aber gerade eben geprüft: noch nicht wieder dran.
frischGeradeGeprueft = await spieler('Eben geprüft', '76561198000020002', 30, 1);
// Letzter Kauf vor vier Jahren, Abgleich vor drei Stunden: der Takt ist
// hier sechs Stunden, also noch nicht dran.
ruhend = await spieler('Ruhend', '76561198000020003', 4 * 365, 3);
// Derselbe Fall, aber der Abgleich liegt sieben Stunden zurück: fällig.
ruhendLangeHer = await spieler('Ruhend und überfällig', '76561198000020004', 4 * 365, 7);
// Nie abgeglichen: immer fällig, egal wie lange der Kauf her ist.
nochNieGeprueft = await spieler('Neu hier', '76561198000020005', null, null);
});
it('nimmt einen Spieler mit frischem Kauf nach zwei Stunden wieder dran', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.ok(faellig.includes(frisch));
});
it('lässt ihn in Ruhe, solange die zwei Stunden nicht um sind', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.equal(faellig.includes(frischGeradeGeprueft), false);
});
it('prüft ein ruhendes Konto nur alle sechs Stunden', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.equal(
faellig.includes(ruhend),
false,
'drei Stunden reichen bei einem Konto ohne Kauf seit Jahren nicht',
);
assert.ok(faellig.includes(ruhendLangeHer), 'nach sieben Stunden aber schon');
});
it('nimmt einen nie geprüften Spieler sofort dran', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.ok(faellig.includes(nochNieGeprueft));
});
it('lässt Spieler ohne Steam-Konto ganz aus', async () => {
const ohne = await db.prisma.player.create({ data: { displayName: 'Ohne Konto' } });
const faellig = await schedule.faelligeSpieler(JETZT);
assert.equal(
faellig.includes(ohne.id),
false,
'ohne Kennung gibt es bei GExtension nichts abzurufen',
);
});
it('zählt ein früheres Konto mit', async () => {
const umzuegler = await db.prisma.player.create({
data: { displayName: 'Nur Alias', gextensionSyncedAt: new Date(JETZT.getTime() - TAG) },
});
await db.prisma.playerAlias.create({
data: { playerId: umzuegler.id, type: 'STEAM_ID', value: '76561198000020099' },
});
const faellig = await schedule.faelligeSpieler(JETZT);
assert.ok(faellig.includes(umzuegler.id), 'seine Kaeufe liegen unter der alten Kennung');
});
});
describe('Wann ein Lauf ansteht', () => {
const taeglichNachts = { intervalMinutes: 1440, onlyAfterHour: 4 };
const allezweiStunden = { intervalMinutes: 120, onlyAfterHour: null };
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 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.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.istFaellig(vorgestern, mittags, taeglichNachts), true);
});
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.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);
});
});
describe('Zwei Läufe zugleich', () => {
it('lässt einen zweiten GExtension-Abgleich nicht daneben laufen', async () => {
// Ein Ziel, das es nicht gibt: der Lauf beginnt, scheitert am Abruf und
// endet - aber solange er läuft, ist die Tür zu.
const ziele = [{ playerId: 1, steamId: '76561198000029999' }];
const ctx = { actorId: null, actorName: 'Test' };
const laufend = scraper.scrapePlayers(ziele, ctx);
assert.equal(scraper.getScrapeState().running, true);
await assert.rejects(
() => scraper.scrapePlayers(ziele, ctx),
(error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING',
'sonst riefen beide dieselben Profile ab',
);
assert.throws(
() => scraper.startScrape(ziele, ctx),
(error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING',
);
await laufend.catch(() => {});
assert.equal(scraper.getScrapeState().running, false, 'danach ist wieder offen');
});
it('gilt für den Zeitplan genauso wie für einen Klick', () => {
// Beide Wege gehen durch dieselbe Funktion - der Zeitplan ruft
// scanForum direkt, die Oberfläche startScan. Die Sperre sitzt deshalb
// unten, nicht im Starter.
assert.equal(typeof forum.scanForum, 'function');
assert.equal(forum.getScanState().running, false);
});
});