Files
Emberfall/tests/integration/e2e-playable.test.js
T
2026-10-21 11:29:48 +11:00

251 lines
11 KiB
JavaScript

// End-to-end "Definition of playable" flow — NOTES.md §7.
// register → log in → create hero → see stats/energy → hunt → gain XP/gold/loot → level up → equip → buy/sell shop → start+claim expedition → leaderboard → log out → log back in → persistence check.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
describe('e2e: Definition of playable', () => {
let BASE, cookie, userSeq = 0;
let db, tmpDir, srv;
before(async () => {
process.env.NODE_ENV = 'test';
// Manual setup matching helpers.js pattern
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emberfall-e2e-'));
const dbPath = path.join(tmpDir, 'e2e.db');
process.env.DB_PATH = dbPath;
// Clear require cache for all src/ and scripts/
const root = path.resolve(__dirname, '..');
for (const id of Object.keys(require.cache)) {
if (id.startsWith(path.join(root, '..') + path.sep) || id.startsWith(path.join(root, '../scripts') + path.sep)) {
delete require.cache[id];
}
}
const { runMigrations } = require('../../scripts/migrate');
runMigrations();
const dbModule = require('../../src/db');
db = dbModule.getDb();
const { createApp } = require('../../src/app');
const app = createApp();
const port = await new Promise((resolve, reject) => {
srv = app.listen(0, () => resolve(srv.address().port));
srv.on('error', reject);
});
BASE = `http://localhost:${port}`;
// Seed a trivial zone/monster for guaranteed wins (hero always wins).
db.prepare(`INSERT OR IGNORE INTO zones (id, slug, name, description, min_level, energy_cost) VALUES (999, 't-e2e', 'Test Zone', 'e2e', 1, 10)`).run();
db.prepare(`INSERT OR IGNORE INTO monsters (id, zone_id, name, level, hp, attack, defense, speed, xp, gold_min, gold_max, weight) VALUES (999, 999, 'Slime', 1, 10, 1, 0, 1, 100, 5, 10, 100)`).run();
// Get a stackable item for loot drops
const lootItem = db.prepare('SELECT id FROM items WHERE stackable = 1 LIMIT 1').get();
if (lootItem) {
db.prepare('INSERT INTO loot_table (monster_id, item_id, chance_pct, qty_min, qty_max) VALUES (?, ?, 100, 1, 1)').run(999, lootItem.id);
}
});
after(async () => {
try { db.close(); } catch (_) {}
try {
await new Promise((r) => { if (srv) srv.close(r); else r(); });
} catch (_) {}
try { fs.rmSync(tmpDir, { recursive: true }); } catch (_) {}
});
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (cookie && !opts.noCookie) headers['Cookie'] = cookie;
const res = await fetch(`${BASE}${path}`, {
method: opts.method || 'GET',
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
redirect: 'manual',
});
const setCookie = res.headers.get('set-cookie');
if (setCookie) cookie = setCookie.split(';')[0];
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) data = await res.json();
else data = await res.text();
return { status: res.status, data, headers: res.headers };
}
async function registerAndLogin(username, password) {
const r1 = await api('/api/auth/register', { method: 'POST', body: { username, password } });
assert.ok([200, 201].includes(r1.status), `register ${username}: ${JSON.stringify(r1.data)}`);
const r2 = await api('/api/auth/login', { method: 'POST', body: { username, password } });
assert.equal(r2.status, 200, `login ${username}: ${JSON.stringify(r2.data)}`);
return cookie;
}
async function loginOnly(username, password) {
const r = await api('/api/auth/login', { method: 'POST', body: { username, password } });
assert.equal(r.status, 200, `login ${username}: ${JSON.stringify(r.data)}`);
return cookie;
}
it('full playable flow: register → login → create hero → stats → hunt → level → equip → shop → expedition → leaderboard → logout → login → persistence', async () => {
userSeq += 1;
const username = `e2euser${userSeq}`;
const password = 'password123';
const heroName = `Hero${userSeq}`;
// 1. REGISTER
// 2. LOGIN
await registerAndLogin(username, password);
// 3. CREATE HERO
const createRes = await api('/api/character', { method: 'POST', body: { name: heroName, class: 'warrior' } });
assert.ok([200, 201].includes(createRes.status), `create hero: ${JSON.stringify(createRes.data)}`);
assert.ok(createRes.data.ok);
const charId = createRes.data.data.id;
// 4. SEE STATS/ENERGY
const charRes = await api('/api/character');
assert.equal(charRes.status, 200);
assert.ok(charRes.data.ok);
const charData = charRes.data.data;
assert.equal(charData.character.name, heroName);
assert.equal(charData.character.class, 'warrior');
assert.ok(charData.energy.current != null, 'Should have energy');
assert.ok(charData.derived != null, 'Should have derived stats');
assert.ok(charData.derived.maxHp != null, 'Should have maxHp');
// 5. HUNT (hero should win against slime)
const huntRes = await api('/api/hunt', { method: 'POST', body: { zoneId: 999 } });
assert.equal(huntRes.status, 200, `hunt: ${JSON.stringify(huntRes.data)}`);
assert.ok(huntRes.data.ok);
const huntData = huntRes.data.data;
assert.equal(huntData.winner, 'hero', 'Hero should win');
assert.ok(huntData.rounds && huntData.rounds.length > 0, 'Should have rounds');
assert.ok(huntData.xpGained > 0, 'Should gain XP');
assert.ok(huntData.goldGained >= 0, 'Should gain gold');
// 6. HUNT MORE TO ACCUMULATE XP (100 XP needed to level 1→2)
let leveledUp = false;
for (let i = 0; i < 6; i++) {
const h2 = await api('/api/hunt', { method: 'POST', body: { zoneId: 999 } });
assert.equal(h2.status, 200, `hunt #${i}: ${JSON.stringify(h2.data)}`);
assert.ok(h2.data.ok);
if (h2.data.data.leveledUp) leveledUp = true;
}
// Verify XP increased
const charAfter = await api('/api/character');
assert.ok(charAfter.data.data.character.xp >= 0, 'XP should be >= 0');
// 7. CHECK INVENTORY (should have loot)
const invRes = await api('/api/inventory');
assert.equal(invRes.status, 200);
assert.ok(invRes.data.ok);
// 8. EQUIP ITEM (if we have unequipped equipment items, equip to weapon slot)
const invData = invRes.data.data.items || [];
if (invData.length > 0) {
const unequipped = invData.find((item) => !item.equipped && item.type !== 'consumable');
if (unequipped) {
const equipRes = await api('/api/inventory/equip', { method: 'POST', body: { itemId: unequipped.itemId, slot: unequipped.slot || 'weapon' } });
assert.equal(equipRes.status, 200, `equip: ${JSON.stringify(equipRes.data)}`);
const inv2 = await api('/api/inventory');
const equippedItem = inv2.data.data.items.find((i) => i.inventoryId === unequipped.inventoryId);
assert.ok(equippedItem && equippedItem.equipped, 'Item should now be equipped');
}
}
// 9. BUY FROM SHOP
const shopRes = await api('/api/shop');
assert.equal(shopRes.status, 200);
assert.ok(shopRes.data.ok);
const stock = shopRes.data.data.stock || [];
const heroGold = charAfter.data.data.character.gold || 0;
if (stock.length > 0) {
const buyable = stock.find((s) => s.buyPrice <= heroGold);
if (buyable) {
const buyRes = await api('/api/shop/buy', { method: 'POST', body: { itemId: buyable.itemId, quantity: 1 } });
assert.equal(buyRes.status, 200, `buy: ${JSON.stringify(buyRes.data)}`);
assert.ok(buyRes.data.ok, `Buy should succeed: ${JSON.stringify(buyRes.data)}`);
}
}
// 10. SELL AN ITEM VIA SHOP (if we have material-type items)
const invAfterBuy = await api('/api/inventory');
const itemsToSell = (invAfterBuy.data.data.items || []).filter((item) => !item.equipped && item.type === 'material');
if (itemsToSell.length > 0) {
// POST /api/shop/sell requires { itemId, quantity }
const sellRes = await api('/api/shop/sell', { method: 'POST', body: { itemId: itemsToSell[0].itemId, quantity: 1 } });
assert.ok([200, 400].includes(sellRes.status), `sell status ${sellRes.status}: ${JSON.stringify(sellRes.data)}`);
}
// 11. START + CLAIM EXPEDITION
const expDefs = await api('/api/expeditions/defs');
assert.equal(expDefs.status, 200);
const defs = expDefs.data.data || [];
let activeExpId = null;
if (defs.length > 0) {
const startRes = await api('/api/expeditions/start', { method: 'POST', body: { defId: defs[0].id } });
assert.equal(startRes.status, 200, `start expedition: ${JSON.stringify(startRes.data)}`);
assert.ok(startRes.data.ok);
activeExpId = startRes.data.data.expedition.id;
// Check active
const activeRes = await api('/api/expeditions/active');
assert.equal(activeRes.status, 200);
assert.ok(activeRes.data.ok);
assert.ok(activeRes.data.data != null, 'Should have active expedition');
// Fast-forward time past ends_at by updating the expedition in DB
if (activeExpId) {
const time = require('../../src/utils/time');
// Set ends_at to 10 minutes ago
db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(time.now() - 600_000, activeExpId);
}
// Claim
const claimRes = await api('/api/expeditions/claim', { method: 'POST', body: { expeditionId: activeExpId } });
assert.equal(claimRes.status, 200, `claim expedition: ${JSON.stringify(claimRes.data)}`);
assert.ok(claimRes.data.ok);
// Verify no active expedition left
const activeAfter = await api('/api/expeditions/active');
assert.equal(activeAfter.status, 200);
assert.ok(activeAfter.data.data == null, 'No active expedition after claim');
}
// 12. SEE LEADERBOARD (level, gold, kills)
const lbLevel = await api('/api/leaderboard?type=level');
assert.equal(lbLevel.status, 200);
assert.ok(lbLevel.data.ok);
assert.ok(Array.isArray(lbLevel.data.data.entries), 'Level leaderboard should have entries array');
const lbGold = await api('/api/leaderboard?type=gold');
assert.equal(lbGold.status, 200);
assert.ok(lbGold.data.ok);
const lbKills = await api('/api/leaderboard?type=kills');
assert.equal(lbKills.status, 200);
assert.ok(lbKills.data.ok);
// 13. LOG OUT
const logoutRes = await api('/api/auth/logout', { method: 'POST' });
assert.equal(logoutRes.status, 200);
// 14. LOG BACK IN (just login, not register)
await loginOnly(username, password);
// 15. VERIFY PERSISTENCE
const charPersist = await api('/api/character');
assert.equal(charPersist.status, 200);
assert.equal(charPersist.data.data.character.name, heroName);
assert.equal(charPersist.data.data.character.class, 'warrior');
assert.ok(charPersist.data.data.energy.current >= 0, 'Energy should have regenerated');
assert.ok(charPersist.data.data.character.gold >= 0, 'Gold should be non-negative');
assert.ok(charPersist.data.data.character.xp >= 0, 'XP should persist');
});
});