// Expedition integration tests: POST /api/expeditions/start, GET /api/expeditions/active, POST /api/expeditions/claim. const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { setup, teardown, cleanup } = require('../helpers'); /** * Register a user and log them in, returning the session cookie string. */ async function registerAndLogin(baseUrl, username, password) { await fetch(`${baseUrl}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const res = await fetch(`${baseUrl}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200); const json = await res.json(); assert.equal(json.ok, true); const setCookie = res.headers.get('set-cookie'); assert.ok(setCookie, 'Login should set a session cookie'); const cookie = setCookie.split(';')[0]; return cookie; } function authHeaders(cookie) { return { Cookie: cookie }; } describe('expeditions', () => { let baseUrl, userSeq = 0; before(async () => { baseUrl = await setup(); }); after(async () => { await teardown(); cleanup(); }); /** * Register, login, create a character, optionally tweak DB fields, return cookie + characterId. */ async function setupHero(overrides = {}) { userSeq += 1; const username = `expu${userSeq}`; const cookie = await registerAndLogin(baseUrl, username, 'password123'); const charRes = await fetch(`${baseUrl}/api/character`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ name: `ExpHero${userSeq}`, class: 'warrior' }), }); assert.equal(charRes.status, 201); const charJson = await charRes.json(); const characterId = charJson.data.character.id; const db = require('../../src/db').getDb(); const cols = Object.keys(overrides); if (cols.length) { db.prepare(`UPDATE characters SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`) .run(...Object.values(overrides), characterId); } return { cookie, characterId }; } // ─── POST /api/expeditions/start ────────────────────────────────────── it('starts a scout expedition (10 min, defId=1)', async () => { const { cookie, characterId } = await setupHero({ gold: 500, energy: 50 }); const res = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); assert.ok(body.data.expedition, 'should return expedition object'); assert.equal(body.data.definition.slug, 'scout-old-road'); assert.equal(body.data.definition.energyCost, 10); // Energy should be deducted assert.equal(body.data.character.energy, 40); }); it('rejects start when another expedition is already active', async () => { const { cookie, characterId } = await setupHero({ gold: 500, energy: 80, level: 3 }); // Start first expedition (scout, defId=1). const res1 = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), }); assert.equal(res1.status, 200); // Try to start a second expedition (raid, defId=2). const res2 = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 2 }), }); const body2 = await res2.json(); assert.equal(res2.status, 409); assert.equal(body2.ok, false); assert.equal(body2.error.code, 'ACTIVE_EXPEDITION'); }); it('rejects start when energy is insufficient', async () => { const { cookie } = await setupHero({ gold: 500, energy: 10, level: 3 }); // Raid costs 30 energy. const res = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 2 }), }); const body = await res.json(); assert.equal(res.status, 422); assert.equal(body.ok, false); assert.equal(body.error.code, 'INSUFFICIENT_ENERGY'); }); it('rejects start when character level is too low', async () => { const { cookie } = await setupHero({ gold: 500, energy: 100, level: 1 }); // Delve requires level 5. const res = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 3 }), }); const body = await res.json(); assert.equal(res.status, 422); assert.equal(body.ok, false); assert.equal(body.error.code, 'LEVEL_TOO_LOW'); }); it('rejects start with invalid defId', async () => { const { cookie } = await setupHero({ gold: 500, energy: 100 }); const res = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 999 }), }); const body = await res.json(); assert.equal(res.status, 404); assert.equal(body.ok, false); assert.equal(body.error.code, 'NOT_FOUND'); }); // ─── GET /api/expeditions/active ────────────────────────────────────── it('returns null when no active expedition', async () => { const { cookie } = await setupHero(); const res = await fetch(`${baseUrl}/api/expeditions/active`, { headers: authHeaders(cookie), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); assert.equal(body.data, null); }); it('returns active expedition with timeRemaining', async () => { const { cookie } = await setupHero({ energy: 80 }); // Start an expedition first. await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), }); const res = await fetch(`${baseUrl}/api/expeditions/active`, { headers: authHeaders(cookie), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); assert.ok(body.data, 'should return expedition data'); assert.ok(body.data.timeRemaining > 0, 'should have positive timeRemaining'); assert.equal(body.data.isFinished, false); assert.equal(body.data.definition.slug, 'scout-old-road'); }); // ─── POST /api/expeditions/claim ────────────────────────────────────── it('rejects claim when expedition is not finished', async () => { const { cookie, characterId } = await setupHero({ energy: 80 }); // Start a 10-min expedition (not finished yet). const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), }); const startBody = await startRes.json(); const expeditionId = startBody.data.expedition.id; // Try to claim immediately — should fail. const res = await fetch(`${baseUrl}/api/expeditions/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ expeditionId }), }); const body = await res.json(); assert.equal(res.status, 409); assert.equal(body.ok, false); assert.equal(body.error.code, 'NOT_FINISHED'); }); it('grants XP and gold when expedition is claimed', async () => { const { cookie, characterId } = await setupHero({ energy: 80, level: 1, xp: 0, gold: 50 }); // Start a scout expedition normally. const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), }); const startBody = await startRes.json(); const expeditionId = startBody.data.expedition.id; // Fast-forward time past the 10-min scout duration by directly setting ends_at in DB. // (setNow can't work because it also shifts ends_at.) const db = require('../../src/db').getDb(); const pastTime = Date.now() - 600000; // 10 min ago db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(pastTime, expeditionId); // Claim it. const res = await fetch(`${baseUrl}/api/expeditions/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ expeditionId }), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); assert.equal(body.data.xpGained, 30); // Scout XP reward assert.ok(body.data.goldGained >= 5 && body.data.goldGained <= 15, 'gold should be in range [5,15]'); // Verify character was updated. const ch = db.prepare('SELECT xp, gold FROM characters WHERE id = ?').get(characterId); assert.equal(ch.xp, 30, 'XP should be 30'); assert.equal(ch.gold, 50 + body.data.goldGained, 'gold should increase'); }); it('rejects double-claim (claimed_at already set)', async () => { const { cookie, characterId } = await setupHero({ energy: 80 }); // Start a scout expedition. const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), }); const startBody = await startRes.json(); const expeditionId = startBody.data.expedition.id; // Fast-forward time past the 10-min scout duration via DB. const db = require('../../src/db').getDb(); const pastTime = Date.now() - 600000; // 10 min ago db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(pastTime, expeditionId); // Claim once. await fetch(`${baseUrl}/api/expeditions/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ expeditionId }), }); // Try to claim again. const res = await fetch(`${baseUrl}/api/expeditions/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ expeditionId }), }); const body = await res.json(); assert.equal(res.status, 409); assert.equal(body.ok, false); assert.equal(body.error.code, 'ALREADY_CLAIMED'); }); it('rejects claim for expedition that does not belong to user', async () => { const { cookie, characterId } = await setupHero({ energy: 80 }); // Create another user + character. userSeq += 1; const otherCookie = await registerAndLogin(baseUrl, `expu_other`, 'password123'); const charRes = await fetch(`${baseUrl}/api/character`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(otherCookie) }, body: JSON.stringify({ name: `ExpHeroOther`, class: 'mage' }), }); const charJson = await charRes.json(); const otherCharacterId = charJson.data.character.id; // Grant an expedition to the other character. const db = require('../../src/db').getDb(); const now = Date.now(); const insertRes = db.prepare('INSERT INTO expeditions (character_id, def_id, started_at, ends_at) VALUES (?, ?, ?, ?)') .run(otherCharacterId, 1, now - 600000, now - 500000); const otherExpeditionId = insertRes.lastInsertRowid; // Try to claim the other user's expedition. const res = await fetch(`${baseUrl}/api/expeditions/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ expeditionId: otherExpeditionId }), }); const body = await res.json(); assert.equal(res.status, 403); // expedition exists but belongs to another user assert.equal(body.ok, false); assert.equal(body.error.code, 'FORBIDDEN'); }); it('active expedition shows isFinished=true after ends_at', async () => { const { cookie } = await setupHero({ energy: 80 }); // Start a scout expedition. await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), }); // Fast-forward time past the 10-min scout duration via DB. const db = require('../../src/db').getDb(); const pastTime = Date.now() - 600000; // 10 min ago db.prepare('UPDATE expeditions SET ends_at = ? WHERE character_id IN (SELECT id FROM characters WHERE id IN (SELECT character_id FROM expeditions WHERE claimed_at IS NULL))').run(pastTime); const res = await fetch(`${baseUrl}/api/expeditions/active`, { headers: authHeaders(cookie), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); assert.equal(body.data.isFinished, true, 'should show finished after ends_at'); assert.equal(body.data.timeRemaining, 0); }); // ─── GET /api/expeditions/defs ──────────────────────────────────────── it('returns all expedition definitions ordered by min_level', async () => { const { cookie } = await setupHero(); const res = await fetch(`${baseUrl}/api/expeditions/defs`, { headers: authHeaders(cookie), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); assert.ok(Array.isArray(body.data), 'should return an array'); assert.equal(body.data.length, 3, 'should have 3 expedition definitions'); // First definition should be the lowest-level one. assert.equal(body.data[0].id, 1); assert.equal(body.data[0].minLevel, 1); assert.equal(body.data[0].name, 'Scout the Old Road'); assert.equal(body.data[0].energyCost, 10); assert.equal(body.data[0].durationSec, 600); assert.equal(body.data[0].xpReward, 30); assert.equal(body.data[0].goldMin, 5); assert.equal(body.data[0].goldMax, 15); // Second definition. assert.equal(body.data[1].id, 2); assert.equal(body.data[1].minLevel, 3); assert.equal(body.data[1].name, 'Raid the Bandit Camp'); // Third definition. assert.equal(body.data[2].id, 3); assert.equal(body.data[2].minLevel, 5); assert.equal(body.data[2].name, 'Delve the Hollow'); }); it('rejects defs endpoint without authentication', async () => { const res = await fetch(`${baseUrl}/api/expeditions/defs`); const body = await res.json(); assert.equal(res.status, 401); assert.equal(body.ok, false); }); // ─── 10.1: expedition start must deduct energy correctly ────────────── it('10.1: starts expedition → DB energy = energy - cost, timestamp NOT in future', async () => { const { cookie, characterId } = await setupHero({ energy: 100, gold: 500 }); const res = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), // scout, cost=10 }); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.ok, true); // Assert DB state directly — this is the core of 10.1. const db = require('../../src/db').getDb(); const ch = db.prepare('SELECT energy, energy_updated_at FROM characters WHERE id = ?').get(characterId); assert.equal(ch.energy, 90, 'DB energy must be 100 - 10 = 90'); assert.ok(ch.energy_updated_at <= Date.now(), 'energy_updated_at must NOT be in the future'); // Assert GET /api/character returns the same energy. const charRes = await fetch(`${baseUrl}/api/character`, { headers: authHeaders(cookie), }); assert.equal(charRes.status, 200); const charBody = await charRes.json(); assert.equal(charBody.data.energy.current, 90); }); it('10.1: starting at energy 55 → DB energy = 45', async () => { const { cookie, characterId } = await setupHero({ energy: 55, gold: 500 }); const res = await fetch(`${baseUrl}/api/expeditions/start`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ defId: 1 }), // scout, cost=10 }); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.ok, true); const db = require('../../src/db').getDb(); const ch = db.prepare('SELECT energy FROM characters WHERE id = ?').get(characterId); assert.equal(ch.energy, 45, 'DB energy must be 55 - 10 = 45'); }); });