// Shop integration tests: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell. 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) { // Register await fetch(`${baseUrl}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); // Login — extract the session cookie from Set-Cookie 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); // Extract session cookie const setCookie = res.headers.get('set-cookie'); assert.ok(setCookie, 'Login should set a session cookie'); const cookie = setCookie.split(';')[0]; // "connect.sid=..." return cookie; } function authHeaders(cookie) { return { Cookie: cookie }; } describe('shop', () => { 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 = {}, characterClass = 'warrior') { userSeq += 1; const username = `shopu${userSeq}`; const cookie = await registerAndLogin(baseUrl, username, 'password123'); // Create character const charRes = await fetch(`${baseUrl}/api/character`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ name: `ShopHero${userSeq}`, class: characterClass }), }); assert.equal(charRes.status, 201); const charJson = await charRes.json(); const characterId = charJson.data.character.id; // Apply overrides via DB 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 }; } /** Grant an item to a character's inventory. Stacks with existing rows. */ function grantItem(characterId, itemId, quantity = 1) { const db = require('../../src/db').getDb(); const NOW = 1_700_000_000_000; const existing = db.prepare( 'SELECT id, quantity FROM inventory WHERE character_id = ? AND item_id = ?' ).get(characterId, itemId); if (existing) { db.prepare('UPDATE inventory SET quantity = quantity + ? WHERE id = ?').run(quantity, existing.id); } else { db.prepare('INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)') .run(characterId, itemId, quantity, NOW); } } // ─── GET /api/shop ──────────────────────────────────────────────────── it('returns shop stock with purchasable items and sellable materials', async () => { const { cookie } = await setupHero({ gold: 500 }); const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); assert.ok(Array.isArray(body.data.stock)); // Should have items with buy_price > 0 (purchasable) const purchasable = body.data.stock.filter((i) => i.buyPrice > 0); assert.ok(purchasable.length > 0, 'should have purchasable items'); // Should have materials (buy_price = 0) const materials = body.data.stock.filter((i) => i.buyPrice === 0); assert.ok(materials.length > 0, 'should have materials for selling'); // Character gold should be present assert.equal(body.data.character.gold, 500); }); it('filters stock by character level (levelOk flag)', async () => { const { cookie } = await setupHero({ gold: 500, level: 1 }); const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) }); const body = await res.json(); assert.equal(res.status, 200); // A level-1 hero should see common items (min_level 1) as levelOk=true const commonItems = body.data.stock.filter((i) => i.levelReq === 1 && i.buyPrice > 0); assert.ok(commonItems.length > 0); for (const item of commonItems) { assert.equal(item.levelOk, true); } // Should NOT see high-level items const highLevelItems = body.data.stock.filter((i) => i.levelReq >= 10); assert.equal(highLevelItems.length, 0, 'should not show level-10+ items to level-1 hero'); }); it('includes sellPrice for materials', async () => { const { cookie } = await setupHero({ gold: 100, level: 5 }); const res = await fetch(`${baseUrl}/api/shop`, { headers: authHeaders(cookie) }); const body = await res.json(); const materials = body.data.stock.filter((i) => i.buyPrice === 0); assert.ok(materials.length > 0); for (const mat of materials) { assert.ok(typeof mat.sellPrice === 'number', `material ${mat.name} should have sellPrice`); assert.ok(mat.sellPrice >= 1, `material ${mat.name} sellPrice should be >= 1`); } }); // ─── POST /api/shop/buy ─────────────────────────────────────────────── it('buys an item and deducts gold', async () => { const { cookie } = await setupHero({ gold: 100 }); // Buy Rusty Sword (item 1, buy_price 20) const res = await fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 1, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); // Gold should decrease by 20 assert.equal(body.data.character.gold, 80); // Rusty Sword should be in inventory const rustySword = body.data.items.find((i) => i.itemId === 1); assert.ok(rustySword, 'Rusty Sword should be in inventory'); assert.equal(rustySword.quantity, 1); }); it('buys multiple quantity and stacks into existing item', async () => { const { cookie } = await setupHero({ gold: 200 }); // Buy 2 Minor Healing Potions (item 14, stackable, buy_price 15). // Starter kit already has 2 potions → total 4 after this buy. const res1 = await fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 14, quantity: 2 }), }); const body1 = await res1.json(); assert.equal(body1.ok, true); assert.equal(body1.data.character.gold, 170); // 200 - 2*15 const potion1 = body1.data.items.find((i) => i.itemId === 14); assert.equal(potion1.quantity, 4); // 2 starter + 2 bought // Buy 3 more of the same → total 7. const res2 = await fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 14, quantity: 3 }), }); const body2 = await res2.json(); assert.equal(body2.ok, true); assert.equal(body2.data.character.gold, 125); // 170 - 3*15 const potion2 = body2.data.items.find((i) => i.itemId === 14); assert.equal(potion2.quantity, 7); // 4 + 3 stacked }); it('rejects buy with insufficient gold', async () => { const { cookie } = await setupHero({ gold: 10 }); // Try to buy Rusty Sword (cost 20) with only 10 gold const res = await fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 1, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 422); assert.equal(body.ok, false); assert.equal(body.error.code, 'INSUFFICIENT_GOLD'); // Error responses don't include data — gold unchanged is implicit }); it('rejects buy for level-gated item', async () => { const { cookie } = await setupHero({ gold: 1000, level: 1 }); // Try to buy Iron Blade (item 4, min_level 5) const res = await fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 4, quantity: 1 }), }); 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 buy for non-purchasable material', async () => { const { cookie } = await setupHero({ gold: 100 }); // Try to buy Rat Tail (item 17, material with buy_price 0) const res = await fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 17, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 400); assert.equal(body.ok, false); assert.equal(body.error.code, 'VALIDATION_ERROR'); }); it('concurrent double-buy: only one succeeds when gold covers exactly one', async () => { const { cookie, characterId } = await setupHero({ gold: 20 }); // Rusty Sword costs 20 gold — exactly enough for one. // Send two buy requests concurrently. const [resA, resB] = await Promise.all([ fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 1, quantity: 1 }), }), fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 1, quantity: 1 }), }), ]); const bodyA = await resA.json(); const bodyB = await resB.json(); // Exactly one should succeed, the other should fail with INSUFFICIENT_GOLD. const success = [bodyA, bodyB].filter((b) => b.ok === true); const failures = [bodyA, bodyB].filter((b) => b.ok === false); assert.equal(success.length, 1, 'exactly one buy should succeed'); assert.equal(failures.length, 1, 'exactly one buy should fail'); assert.equal(failures[0].error.code, 'INSUFFICIENT_GOLD'); // Gold should be 0 after one successful buy. const winner = success[0]; assert.equal(winner.data.character.gold, 0); }); // ─── POST /api/shop/sell ────────────────────────────────────────────── it('sells a material and increases gold', async () => { const { cookie, characterId } = await setupHero({ gold: 50 }); // Grant Rat Tail (item 17, material, common, min_level 1, buy_price 0) grantItem(characterId, 17, 1); const beforeGold = db_prepareSelect('SELECT gold FROM characters WHERE id = ?', characterId).gold; assert.equal(beforeGold, 50); // Sell the Rat Tail const res = await fetch(`${baseUrl}/api/shop/sell`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 17, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); // Gold should increase assert.ok(body.data.character.gold > 50, `gold should increase (was 50, now ${body.data.character.gold})`); // Rat Tail should be removed from inventory const ratTail = body.data.items.find((i) => i.itemId === 17); assert.equal(ratTail, undefined, 'Rat Tail should be removed from inventory'); }); it('sells materials with rarity default pricing', async () => { const { cookie, characterId } = await setupHero({ gold: 100, level: 5 }); // Grant Spider Silk (item 19, material, uncommon, min_level 5, buy_price 0) grantItem(characterId, 19, 1); const res = await fetch(`${baseUrl}/api/shop/sell`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 19, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); // Uncommon material sell: floor(4 * level / min_level) = floor(4 * 5 / 5) = 4 assert.equal(body.data.character.gold, 104); // 100 + 4 }); it('sells uncommon material using rarity default', async () => { const { cookie, characterId } = await setupHero({ gold: 100, level: 10 }); // Grant Imp Horn (item 20, material, uncommon, min_level 10, buy_price 0) grantItem(characterId, 20, 1); const res = await fetch(`${baseUrl}/api/shop/sell`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 20, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 200); assert.equal(body.ok, true); // Uncommon material sell: floor(4 * 10 / 10) = 4 assert.equal(body.data.character.gold, 104); // 100 + 4 }); it('rejects selling a non-material item', async () => { const { cookie, characterId } = await setupHero({ gold: 100 }); // Grant Rusty Sword (item 1, weapon — not a material) grantItem(characterId, 1, 1); const res = await fetch(`${baseUrl}/api/shop/sell`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 1, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 400); assert.equal(body.ok, false); assert.equal(body.error.code, 'VALIDATION_ERROR'); }); it('rejects selling an item not in inventory', async () => { const { cookie } = await setupHero({ gold: 100 }); // Try to sell Rat Tail without granting it first const res = await fetch(`${baseUrl}/api/shop/sell`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 17, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 404); assert.equal(body.ok, false); assert.equal(body.error.code, 'NOT_FOUND'); }); // ─── Sell equipped item rejection ───────────────────────────────────── it('rejects selling an equipped item', async () => { // Use a ranger (starter weapon is Hunters Shortbow, item 3). // Grant a Leather Jerkin (item 8, armor) and equip it, then try to sell. const { cookie, characterId } = await setupHero({ gold: 100 }, 'ranger'); grantItem(characterId, 8, 1); // Leather Jerkin (armor) const db = require('../../src/db').getDb(); db.prepare('UPDATE inventory SET equipped_slot = ? WHERE character_id = ? AND item_id = ?').run('armor', characterId, 8); // Try to sell the equipped Leather Jerkin — not allowed. const res = await fetch(`${baseUrl}/api/shop/sell`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 8, quantity: 1 }), }); const body = await res.json(); assert.equal(res.status, 400); assert.equal(body.ok, false); assert.equal(body.error.code, 'VALIDATION_ERROR'); assert.ok(body.error.message.includes('Unequip'), 'should mention unequipping'); }); // ─── Round-trip ─────────────────────────────────────────────────────── it('buy and sell round-trip: buy then sell loses gold (spread)', async () => { const { cookie, characterId } = await setupHero({ gold: 500 }); // Buy Minor Healing Potion (item 14, buy_price 15, consumable) const buyRes = await fetch(`${baseUrl}/api/shop/buy`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 14, quantity: 1 }), }); const buyBody = await buyRes.json(); assert.equal(buyRes.status, 200); assert.equal(buyBody.ok, true); const buyGold = buyBody.data.character.gold; // 500 - 15 = 485 assert.equal(buyGold, 485); // Sell Rat Tail (item 17, material, common, min_level 1) — must grant it first grantItem(characterId, 17, 1); const sellRes = await fetch(`${baseUrl}/api/shop/sell`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, body: JSON.stringify({ itemId: 17, quantity: 1 }), }); const sellBody = await sellRes.json(); assert.equal(sellRes.status, 200); assert.equal(sellBody.ok, true); const sellGold = sellBody.data.character.gold; // Rat Tail (common, min_level 1) sell: floor(2 * level / 1) = floor(2 * 1 / 1) = 2 // Final gold = 485 + 2 = 487 assert.equal(sellGold, 487); assert.ok(sellGold < 500, 'round-trip should lose gold due to spread'); // Rat Tail should be gone from inventory const db = require('../../src/db').getDb(); const invAfter = db.prepare('SELECT * FROM inventory WHERE character_id = ? AND item_id = 17') .get(characterId); assert.equal(invAfter, undefined, 'Rat Tail should be removed after sell'); }); }); function db_prepareSelect(sql, ...params) { return require('../../src/db').getDb().prepare(sql).get(...params); }