Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket. Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der Spielerseite. Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung warten und bei wem. Ausserdem: - Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen. - Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten. - Die Hinweisbox ueber den Antraegen beim Spieler faellt weg. - Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem Tabellenlayout gefallen, daher die versetzte Trennlinie. - Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier" nach UTC und im Sommer zwei Stunden daneben.
169 lines
5.8 KiB
TypeScript
169 lines
5.8 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 Db = typeof import('../src/db.js');
|
|
|
|
let database: TestDatabase;
|
|
let schedule: Schedule;
|
|
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');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|