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'; // Der Sperr-Test stösst einen echten Abruf an. Er soll sofort scheitern // statt in ein Zeitlimit zu laufen - und vor allem nicht wirklich bei // GExtension anklopfen, nur weil hier Tests laufen. process.env.GEXTENSION_BASE_URL = 'http://127.0.0.1:1'; 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); }); });