step 6.1: wip (not ticked)
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
// 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 = {}) {
|
||||
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: 'warrior' }),
|
||||
});
|
||||
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. */
|
||||
function grantItem(characterId, itemId, quantity = 1) {
|
||||
const db = require('../../src/db').getDb();
|
||||
const NOW = 1_700_000_000_000;
|
||||
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)
|
||||
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, 2);
|
||||
|
||||
// Buy 3 more of the same
|
||||
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, 5); // 2 + 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');
|
||||
});
|
||||
|
||||
// ─── 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');
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
Reference in New Issue
Block a user