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.
279 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|