/** * Unit tests for server/db.mjs (SQLite persistence layer) and the shared * crystal formula. Runs fully isolated in a temp DATA_DIR – no server needed. * Run: node scripts/test-db.mjs */ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trxtd-db-test-')) process.env.DATA_DIR = tmp const db = await import('../server/db.mjs') const { calcCrystalsEarned } = await import('../shared/meta-upgrades.mjs') let passed = 0 let failed = 0 async function check(name, fn) { try { await fn() passed++ console.log(` OK ${name}`) } catch (e) { failed++ console.error(` FAIL ${name}: ${e.message}`) } } const eq = (a, b, msg) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${msg || 'ungleich'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`) } // ---------------------------------------------------------------- passwords await check('scrypt: korrektes Passwort verifiziert', async () => { const hash = await db.hashPassword('supersicher123') if (await db.verifyPassword('supersicher123', hash) !== true) throw new Error('sollte true sein') }) await check('scrypt: falsches Passwort abgelehnt', async () => { const hash = await db.hashPassword('supersicher123') if (await db.verifyPassword('falschfalsch', hash) !== false) throw new Error('sollte false sein') }) await check('scrypt: kaputte/fehlende Hashes sind false', async () => { if (await db.verifyPassword('x', null) !== false) throw new Error('null-Hash') if (await db.verifyPassword('x', 'kein-doppelpunkt') !== false) throw new Error('ohne Doppelpunkt') }) // ---------------------------------------------------------------- users let alice = null await check('createUserLocal: Nutzer mit Stats & 0 Kristallen', async () => { alice = db.createUserLocal('Alice_01', 'Alice', await db.hashPassword('passwort123')) if (!alice) throw new Error('kein Nutzer') if (alice.username !== 'alice_01') throw new Error('username nicht lowercase') if (alice.crystals !== 0) throw new Error('crystals != 0') eq(alice.upgrades, {}, 'upgrades nicht leer') eq(alice.stats, { gamesPlayed: 0, gamesWon: 0, totalKills: 0, totalScore: 0, highestWave: 0 }) }) await check('getUserByUsername: case-insensitiv, unbekannt = null', () => { if (!db.getUserByUsername('ALICE_01')) throw new Error('sollte gefunden werden') if (db.getUserByUsername('nobody')) throw new Error('sollte null sein') }) await check('createUserLocal: doppelter Username wirft', async () => { let threw = false try { db.createUserLocal('alice_01', 'Alice2', await db.hashPassword('passwort123')) } catch { threw = true } if (!threw) throw new Error('UNIQUE-Constraint nicht ausgelöst') }) // ---------------------------------------------------------------- sessions let bob = null await check('Sessions: create/get/delete Zyklus', async () => { bob = db.createUserLocal('bob', 'Bob', await db.hashPassword('passwort123')) const sess = db.createSession(bob.id) const found = db.getSession(sess.token) if (!found || found.user_id !== bob.id) throw new Error('Session nicht gefunden') if (db.getSession('falscher-token')) throw new Error('falscher Token sollte null sein') db.deleteSession(sess.token) if (db.getSession(sess.token)) throw new Error('Session sollte gelöscht sein') }) await check('Sessions: abgelaufene Session ungültig + Cleanup', () => { const expired = db.createSession(bob.id, -0.001) // läuft sofort ab if (db.getSession(expired.token)) throw new Error('abgelaufene Session darf nicht gelten') db.cleanExpiredSessions() // darf nicht werfen }) // ---------------------------------------------------------------- crystals & upgrades await check('addCrystals: summiert Kristalle', () => { const after = db.addCrystals(bob.id, 2000) if (after.crystals !== 2000) throw new Error(`crystals ${after.crystals}`) }) await check('buyUpgrade: zu wenig Kristalle abgelehnt', () => { const poor = db.createUserLocal('poor', 'Poor', 'x'.repeat(40)) const r = db.buyUpgrade(poor.id, 'start_gold', 30, 5) if (r.ok) throw new Error('sollte fehlschlagen') if (!r.error.includes('Kristalle')) throw new Error(`unerwartete Fehlermeldung: ${r.error}`) }) await check('buyUpgrade: 5 Stufen zu korrekten Kosten, dann Max erreicht', () => { const costs = [30, 70, 150, 300, 600] for (let i = 0; i < 5; i++) { const r = db.buyUpgrade(bob.id, 'start_gold', costs[i], 5) if (!r.ok) throw new Error(`Stufe ${i + 1} fehlgeschlagen: ${r.error}`) if (r.user.upgrades.start_gold !== i + 1) throw new Error(`falsche Stufe ${r.user.upgrades.start_gold}`) if (r.user.crystals !== 2000 - costs.slice(0, i + 1).reduce((a, b) => a + b, 0)) { throw new Error(`falscher Kristallstand: ${r.user.crystals}`) } } const over = db.buyUpgrade(bob.id, 'start_gold', 1, 5) if (over.ok) throw new Error('Maximalstufe nicht erkannt') }) // ---------------------------------------------------------------- guest merge await check('mergeGuest: Kristalle gedeckelt, Levels gedeckelt, Unbekanntes ignoriert', () => { const guest = db.createUserLocal('guest', 'Guest', 'x'.repeat(40)) const maxLevels = { start_gold: 5, dot_potency: 3 } const merged = db.mergeGuest(guest.id, 999999, { start_gold: 99, dot_potency: 2, nonexistent: 5 }, maxLevels) if (merged.crystals !== 100000) throw new Error(`Kristalle nicht auf 100000 gedeckelt: ${merged.crystals}`) if (merged.upgrades.start_gold !== 5) throw new Error(`start_gold nicht auf 5 gedeckelt: ${merged.upgrades.start_gold}`) if (merged.upgrades.dot_potency !== 2) throw new Error(`dot_potency falsch: ${merged.upgrades.dot_potency}`) if (merged.upgrades.nonexistent) throw new Error('unbekanntes Upgrade darf nicht übernommen werden') }) await check('mergeGuest: negatives/ungültiges Input wird neutralisiert', () => { const guest = db.createUserLocal('guest2', 'Guest2', 'x'.repeat(40)) const maxLevels = { start_gold: 5 } const merged = db.mergeGuest(guest.id, -50, { start_gold: -3 }, maxLevels) if (merged.crystals !== 0) throw new Error('negative Kristalle müssen 0 ergeben') if (merged.upgrades.start_gold) throw new Error('negative Level müssen ignoriert werden') }) await check('mergeGuest: höherer Account-Level bleibt erhalten (MAX)', () => { const rich = db.createUserLocal('rich', 'Rich', 'x'.repeat(40)) db.addCrystals(rich.id, 200) db.buyUpgrade(rich.id, 'start_gold', 30, 5) db.buyUpgrade(rich.id, 'start_gold', 70, 5) const merged = db.mergeGuest(rich.id, 10, { start_gold: 1 }, { start_gold: 5 }) if (merged.upgrades.start_gold !== 2) throw new Error(`Gast-Level 1 darf Account-Level 2 nicht überschreiben: ${merged.upgrades.start_gold}`) if (merged.crystals !== 110) throw new Error(`Kristalle: ${merged.crystals}`) }) // ---------------------------------------------------------------- game results await check('recordGameResult: Kristalle & Statistiken akkumulieren', () => { const fresh = db.createUserLocal('stats', 'Stats', 'x'.repeat(40)) let u = db.recordGameResult(fresh.id, { win: true, score: 3000, wave: 12, kills: 55, crystalsEarned: 82 }) if (u.crystals !== 82) throw new Error(`crystals ${u.crystals}`) u = db.recordGameResult(fresh.id, { win: false, score: 500, wave: 5, kills: 10, crystalsEarned: 14 }) if (u.crystals !== 96) throw new Error(`crystals ${u.crystals}`) eq(u.stats, { gamesPlayed: 2, gamesWon: 1, totalKills: 65, totalScore: 3500, highestWave: 12 }) }) await check('recordGameResult: Verlust mit crystalsEarned 0 erhöht keine Kristalle', () => { const fresh = db.createUserLocal('noloss', 'NoLoss', 'x'.repeat(40)) const u = db.recordGameResult(fresh.id, { win: false, score: 0, wave: 1, kills: 0, crystalsEarned: 0 }) if (u.crystals !== 0) throw new Error(`crystals ${u.crystals}`) if (u.stats.gamesPlayed !== 1) throw new Error('Statistik nicht hochgezählt') }) // ---------------------------------------------------------------- crystal formula await check('calcCrystalsEarned: Formel deckungsgleich mit Definition', () => { eq(calcCrystalsEarned(12, 3000, true), 82) eq(calcCrystalsEarned(5, 500, false), 14) eq(calcCrystalsEarned(0, 0, false), 1, 'Minimum 1 Kristall pro Runde') eq(calcCrystalsEarned(10, 1000, false), 29) }) // ---------------------------------------------------------------- teardown db.closeDb() fs.rmSync(tmp, { recursive: true, force: true }) console.log(`\n${passed} bestanden, ${failed} fehlgeschlagen`) process.exit(failed === 0 ? 0 : 1)