In den Kaesten steht jetzt neben dem offenen Stand, wie viel jemals in den Topf kam: "0 von 3" liest sich anders als "0 von 0" - das eine heisst aufgebraucht, das andere nie etwas gehabt. Gezaehlt werden dabei nur echte Zugaenge: Die Ruecknahme eines Verbrauchs zaehlt dorthin, wo die stornierte Zeile zaehlte, sonst saehe ein zurueckgenommenes Redesign wie ein zusaetzliches Paket aus. Neben jedem Plus steht ein Minus, das die zuletzt von Hand eingetragene Zugabe dieser Art zurueckholt - fuer den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. Zurueckgenommen wird ueber eine Gegenbuchung; bei einem CC-Paket gehen beide Zeilen mit. Aus einem Import gebuchte Pakete bleiben aussen vor: die gehoeren zu einer Bestellung und werden dort storniert, wo der Zusammenhang sichtbar ist. Die Rechte folgen dem Eintragen - Kulanz bleibt bei den Admins. Nebenbei zwei wacklige Tests festgezurrt: Die Testdatenbanken weichen auf den naechsten Port aus, wenn einer aus einem frueheren Lauf noch haengt, und der Sperr-Test des Abgleichs klopft nicht mehr bei GExtension an, sondern laeuft gegen eine tote Adresse.
283 lines
11 KiB
TypeScript
283 lines
11 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';
|
|
// 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);
|
|
});
|
|
});
|