Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich nur die obere bedienen. Jetzt beide: Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben. Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei einem Trio kann einer im Rollenspiel sterben, waehrend die anderen weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet neu. Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen, fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt dieselbe Anzeige. Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
208 lines
7.4 KiB
TypeScript
208 lines
7.4 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 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');
|
|
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('Der nächtliche Forumslauf', () => {
|
|
const nacht = 4;
|
|
|
|
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('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 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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('stößt 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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|