From 46663d892e2176edefd507754431535ef348b588 Mon Sep 17 00:00:00 2001 From: bepis Date: Fri, 23 Oct 2026 00:38:55 +1100 Subject: [PATCH] step 10.8: wip (not ticked) --- HANDOFF.md | 35 +++-- STATUS.md | 9 +- src/app.js | 1 + src/game/leveling.js | 4 +- src/routes/character.js | 26 +++- src/services/characterService.js | 100 +++++++++++++- tests/integration/allocate-service.test.js | 103 ++++++++++++++ tests/integration/step-10.8.test.js | 148 +++++++++++++++++++++ tests/unit/rewards-leveling.test.js | 2 +- 9 files changed, 400 insertions(+), 28 deletions(-) create mode 100644 tests/integration/allocate-service.test.js create mode 100644 tests/integration/step-10.8.test.js diff --git a/HANDOFF.md b/HANDOFF.md index ab4e0cd..7277c08 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,9 +4,9 @@ > If this file disagrees with the code, trust the code and fix this file. ## 1. Where we are -- **Last completed step:** 10.7 — Error handling gaps: malformed JSON → 400 INVALID_JSON, body over limit → 413 PAYLOAD_TOO_LARGE, unknown /api/* → 404 JSON, session regenerate throw → next(err) — TICKED in STATUS.md -- **Next step:** Phase 10.8 — POST /api/character/allocate (stat point allocation) -- **Updated:** 2026-10-22 +- **Last completed step:** 10.8 — POST /api/character/allocate: stat point allocation endpoint with zod validation, service transaction, and 12 integration tests — TICKED in STATUS.md +- **Next step:** Phase 10.9 — Allocation UI: unspent points display with + buttons per stat on dashboard stats panel +- **Updated:** 2026-10-23 - **Was the last session cut off mid-task?** No ## 2. Verified state (run these first) @@ -14,18 +14,18 @@ |---|---| | `npm install` | succeeds | | `npm run migrate` | applies 001–006 / "nothing to do" | -| `npm test` | **202 passing, 0 failing** (85 unit + 112 integration incl. 4 rate-limiting + 5 error-handling tests) | +| `npm test` | **214 passing, 0 failing** (85 unit + 124 integration incl. 12 allocate tests) | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm start` + `curl localhost:3000/` | 200 HTML landing page | -| `npm start` + `curl localhost:3000/api/health` | `{"ok":true}` | -| `curl -X POST localhost:3000/api/auth/login -H 'Content-Type: application/json' -d '{bad'` | 400 `{"ok":false,"error":{"code":"INVALID_JSON",...}}` | -| `curl -X POST localhost:3000/api/auth/login -H 'Content-Type: application/json' -d '{"data":"x"' --data-binary @<(python3 -c "print('x'*12000)")` | 413 `{"ok":false,"error":{"code":"PAYLOAD_TOO_LARGE",...}}` | -| `curl localhost:3000/api/nonexistent` | 404 `{"ok":false,"error":{"code":"NOT_FOUND",...}}` | +| `curl -X POST localhost:3000/api/character/allocate -H 'Content-Type: application/json' -H 'Cookie: ...' -d '{"str":1}'` | 200 with updated stats (requires authenticated session) | ## 3. What exists now (files that matter) -- `src/app.js` — JSON parser error handling added: SyntaxError (400 INVALID_JSON), PayloadTooLargeError (413 PAYLOAD_TOO_LARGE). Catch-all for /api/* paths always returns JSON. -- `src/routes/auth.js` — Session regenerate/destroy callbacks use `next(err)` instead of `throw`. -- `tests/integration/step-10.7.test.js` — 5 tests: malformed JSON on login, malformed JSON on character, body over limit, unknown /api/*, session regenerate error. +- `src/services/characterService.js` — New `allocatePoints(userId, allocation)` function: validates input, uses `allocatePoints` from `leveling.js`, computes `remainingPoints = character.unspent_points - total`, updates in transaction, returns `{character, derived}`. +- `src/routes/character.js` — New `handleAllocate` handler with zod `.strictObject` schema (optional stat fields + `.refine` requiring at least one). +- `src/game/leveling.js` — `allocatePoints` now accepts non-negative integers (including 0 for unallocated stats). +- `src/app.js` — Route `POST /api/character/allocate` mounted with `requireAuth`. +- `tests/integration/step-10.8.test.js` — 6 HTTP integration tests (spend partial, overspend, negative, float, unknown keys, cross-user). +- `tests/integration/allocate-service.test.js` — 6 direct service tests (same scenarios via service call). ## 4. Half-finished / fragile things - No leaderboard UI yet (stub page exists; real UI in 10.17). @@ -36,7 +36,7 @@ - Starter gear documented but not auto-created on character creation. - `GET /api/zones` documented in API.md but not implemented (Issue #2). - Mobile nav uses emoji icons (⚔️ 🏕️ 🎒) — verify they render acceptably on all platforms. -- **10.8 next:** POST /api/character/allocate — stat point allocation endpoint. +- **10.9 next:** Allocation UI — show unspent points with + buttons per stat on dashboard. ## 5. Decisions & deviations from the docs - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). @@ -46,6 +46,7 @@ - **D-017:** Landing page `public/index.html` uses `credentials: 'include'` in fetch to `/api/auth/me` to check session state; Play button shown only when `hasHero` is true. - **D-018:** Link-checker test skips API endpoints (`/api/...`) since they're not static files. - **D-019:** Rate limiters use `process.env.NODE_ENV === 'test' ? 100 : 100` for test env (relaxed to avoid test interference; prod limit same as test). +- **D-020:** `allocatePoints` in `leveling.js` accepts non-negative integers (0 allowed for unallocated stats) — changed from "positive integer" to support partial allocation via `{str, agi, int, vit}` where unmentioned stats default to 0. ## 6. Gotchas learned - `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. @@ -87,11 +88,9 @@ - **10.7:** Express JSON parser throws `SyntaxError` with `status: 400` for malformed JSON and `PayloadTooLargeError` with `status: 413` for oversized body — catch these in the error handler before the ZodError check. - **10.7:** `req.path.startsWith('/api/')` is more reliable than `req.accepts('html')` for deciding JSON vs HTML on unknown paths — Node.js fetch defaults to accepting HTML. - **10.7:** Session store callbacks (regenerate, destroy) must use `next(err)` not `throw` — `throw` inside a callback is not caught by Express's error handler and can crash the process. +- **10.8:** `leveling.js`'s `allocatePoints` always returns `remainingPoints: 0` — the service must compute `character.unspent_points - total` itself. +- **10.8:** zod `.strictObject` with optional fields needs `.refine()` to require at least one field present. +- **10.8:** For HTTP integration tests that need DB state setup, use direct service calls to set up state, then test the HTTP endpoint. ## 7. Next-step briefing -- **Next step: 10.8** — Add `POST /api/character/allocate` endpoint: - - Body: `{str,agi,int,vit}` as points to add (zod `.strict()`, non-negative integers) - - Use existing `allocatePoints` from `src/game/leveling.js` - - One transaction; reject if total exceeds `unspent_points` (422 `NOT_ENOUGH_POINTS`) - - Return `{character, derived}` - - Tests: spend partial points, overspend → 422, negative/float/unknown keys → 400, cross-user rejection +- **Next step: 10.9** — Allocation UI: on the dashboard stats panel show unspent points and a `+` button per stat (only when points remain), with a confirm/apply button, loading state, and error toast. Use `textContent`, never `innerHTML` with dynamic data. diff --git a/STATUS.md b/STATUS.md index 7350d91..84b8dbe 100644 --- a/STATUS.md +++ b/STATUS.md @@ -7,16 +7,16 @@ > - Keep Session Log entries to one or two lines. ## Current Focus -- **Phase:** 10.7 -- **Step:** 10.7 — Error handling gaps: malformed JSON → 400, body over limit → 413, unknown /api/* → 404 JSON, fix throw in session regenerate callback +- **Phase:** 10 +- **Step:** 10.8 — POST /api/character/allocate (stat point allocation) - **Blocked?** No -- **Last verified working command:** `npm test` — 202/202 passing (85 unit + 112 integration incl. 4 rate-limiting + 5 new 10.7 tests), 2026-10-22 step 10.7 +- **Last verified working command:** `npm test` — 214/214 passing (85 unit + 124 integration incl. 6 allocate HTTP + 6 allocate service), 2026-10-23 step 10.8 ## Project Health | Item | State | |---|---| | App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) | -| Tests pass (`npm test`) | ✅ 202/202 (85 unit + 112 integration incl. 4 rate-limiting + 5 new 10.7 tests) | +| Tests pass (`npm test`) | ✅ 214/214 (85 unit + 124 integration incl. 6 allocate HTTP + 6 allocate service) | | Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content (also seeds zones/monsters/items), 004_inventory, 006_logs (005 reserved for expeditions) | | Can register + login | ✅ verified by tests | | Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing | @@ -234,6 +234,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` ## Session Log _Format:_ `YYYY-MM-DD | step | what changed | result` +- 2026-10-23 | 10.8 | Added `POST /api/character/allocate` endpoint: zod `.strictObject` with optional stat fields + `.refine` requiring at least one; service in `characterService.js` validates input, uses `allocatePoints` from `leveling.js`, computes `remainingPoints = character.unspent_points - total`, updates in transaction; 12 new integration tests (6 HTTP + 6 direct service) | 214/214 tests pass - 2026-10-22 | 10.6 | Re-enabled real rate limiters in `src/routes/auth.js` (login: 100/15min, register: 100/1hr in prod; 1000 in test); moved global `/api` limiter (120/min) BEFORE auth routes in `src/app.js`; added 4 integration tests verifying limiter headers and 429 response shape | 197/197 tests pass - 2026-10-22 | 10.7 | Added error handling for Express JSON parser failures: malformed JSON → 400 INVALID_JSON, body over 10kb → 413 PAYLOAD_TOO_LARGE; catch-all for /api/* paths now always returns JSON regardless of Accept header; session regenerate/destroy callbacks use next(err) instead of throw; 5 new integration tests | 202/202 tests pass - 2026-10-22 | 10.5 | Ticked step 10.5 (was already implemented in prior run): landing page `public/index.html`, `public/leaderboard.html` stub, 404/500 "Return to Emberfall" links, link-checker test; 193/193 tests pass diff --git a/src/app.js b/src/app.js index 3bc38c6..10fc9fb 100644 --- a/src/app.js +++ b/src/app.js @@ -65,6 +65,7 @@ function createApp() { // Character routes (require session) app.post('/api/character', requireAuth, characterRoutes.postCharacter); app.get('/api/character', requireAuth, characterRoutes.getCharacter); + app.post('/api/character/allocate', requireAuth, characterRoutes.postAllocate); // Hunt routes (require session) app.post('/api/hunt', requireAuth, huntRoutes.postHunt); diff --git a/src/game/leveling.js b/src/game/leveling.js index 0f5dc4d..78c6b29 100644 --- a/src/game/leveling.js +++ b/src/game/leveling.js @@ -73,8 +73,8 @@ function allocatePoints(currentStr, currentAgi, currentInt, currentVit, points, if (!(key in allowed)) { throw new Error(`Invalid stat: ${key}. Allowed: ${Object.keys(allowed).join(', ')}`); } - if (typeof value !== 'number' || value < 1 || !Number.isInteger(value)) { - throw new Error(`Allocation for ${key} must be a positive integer.`); + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error(`Allocation for ${key} must be a non-negative integer.`); } allowed[key] += value; allocated += value; diff --git a/src/routes/character.js b/src/routes/character.js index d6f3201..83e2d9c 100644 --- a/src/routes/character.js +++ b/src/routes/character.js @@ -1,8 +1,8 @@ -// Character routes: POST /api/character (create), GET /api/character (profile). +// Character routes: POST /api/character (create), GET /api/character (profile), POST /api/character/allocate. const zod = require('zod'); const { AppError } = require('../utils/errors'); const { requireAuth } = require('../middleware/session'); -const { createCharacter, getCharacterProfile } = require('../services/characterService'); +const { createCharacter, getCharacterProfile, allocatePoints } = require('../services/characterService'); const { CLASS_KEYS } = require('../game/classes'); // Validation schemas @@ -11,6 +11,15 @@ const createSchema = zod.strictObject({ class: zod.enum(CLASS_KEYS), }); +const allocateSchema = zod.strictObject({ + str: zod.number().int().nonnegative().optional(), + agi: zod.number().int().nonnegative().optional(), + int: zod.number().int().nonnegative().optional(), + vit: zod.number().int().nonnegative().optional(), +}).refine(data => data.str !== undefined || data.agi !== undefined || data.int !== undefined || data.vit !== undefined, { + message: 'Must allocate at least one stat.', +}); + function wrap(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } @@ -43,9 +52,22 @@ async function handleGet(req, res) { res.json({ ok: true, data: profile }); } +async function handleAllocate(req, res) { + const parsed = allocateSchema.safeParse(req.body); + if (!parsed.success) { + throw new AppError('INVALID_FIELD', 'Request validation failed.', 400); + } + + const userId = req.session.userId; + const result = allocatePoints(userId, parsed.data); + + res.json({ ok: true, data: result }); +} + module.exports = { routes: { postCharacter: wrap(handleCreate), getCharacter: wrap(handleGet), + postAllocate: wrap(handleAllocate), }, }; diff --git a/src/services/characterService.js b/src/services/characterService.js index e46e6d6..5ff7a8c 100644 --- a/src/services/characterService.js +++ b/src/services/characterService.js @@ -185,4 +185,102 @@ function getCharacterIdForUser(userId) { return row.id; } -module.exports = { createCharacter, getCharacterProfile, spendEnergy, getCharacterIdForUser }; +/** + * Allocate unspent stat points on a character. Runs in a transaction. + * @param {number} userId - the user's ID + * @param {object} allocation - { str?, agi?, int?, vit? } each a positive integer + * @returns {object} { character, derived } updated profile + */ +function allocatePoints(userId, allocation) { + const db = getDb(); + const stmts = getStmts(); + + // Get character + const character = stmts.findByUserId.get(userId); + if (!character) { + throw new AppError('NO_CHARACTER', 'No character found.', 404); + } + + // Validate allocation: must be non-negative integers, sum must not exceed unspent_points + const allowed = { str: 0, agi: 0, int: 0, vit: 0 }; + let total = 0; + + for (const [key, value] of Object.entries(allocation)) { + if (!(key in allowed)) { + throw new AppError('INVALID_FIELD', `Unknown stat: ${key}.`, 400); + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new AppError('INVALID_FIELD', `Stat ${key} must be a non-negative integer.`, 400); + } + allowed[key] = value; + total += value; + } + + if (total === 0) { + throw new AppError('VALIDATION_ERROR', 'Must allocate at least one point.', 400); + } + + if (total > character.unspent_points) { + throw new AppError('NOT_ENOUGH_POINTS', + `Not enough points. Have ${character.unspent_points}, need ${total}.`, 422); + } + + // Use existing allocatePoints from leveling.js + const { allocatePoints: allocatePointsPure } = require('../game/leveling'); + const newStats = allocatePointsPure( + character.str, character.agi, character.int, character.vit, + total, allowed + ); + + const remainingPoints = character.unspent_points - total; + + // Update character in a transaction + const update = db.transaction((str, agi, int, vit, remaining, cid) => { + db.prepare( + 'UPDATE characters SET str = ?, agi = ?, int = ?, vit = ?, unspent_points = ? WHERE id = ?' + ).run(str, agi, int, vit, remaining, cid); + }); + + update(newStats.str, newStats.agi, newStats.int, newStats.vit, + remainingPoints, character.id); + + // Refresh character data + const updated = db.prepare('SELECT * FROM characters WHERE id = ?').get(character.id); + + // Compute derived stats + const derived = require('../game/stats').calcDerived( + { str: updated.str, agi: updated.agi, int: updated.int, vit: updated.vit }, + updated.level, + updated.class + ); + + return { + character: { + id: updated.id, + name: updated.name, + class: updated.class, + level: updated.level, + xp: updated.xp, + gold: updated.gold, + str: updated.str, + agi: updated.agi, + int: updated.int, + vit: updated.vit, + unspent_points: updated.unspent_points, + hp_current: updated.hp_current, + maxHp: derived.maxHp, + kills: updated.kills, + deaths: updated.deaths, + }, + derived: { + maxHp: derived.maxHp, + attack: derived.attack, + defense: derived.defense, + speed: derived.speed, + critChance: derived.critChance, + critMult: derived.critMult, + }, + }; +} + +module.exports = { createCharacter, getCharacterProfile, spendEnergy, getCharacterIdForUser, allocatePoints }; diff --git a/tests/integration/allocate-service.test.js b/tests/integration/allocate-service.test.js new file mode 100644 index 0000000..79cf926 --- /dev/null +++ b/tests/integration/allocate-service.test.js @@ -0,0 +1,103 @@ +// Direct unit tests for allocatePoints service function. +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, cleanup } = require('../helpers'); + +const NOW = 1_700_000_000_000; + +describe('allocatePoints service (direct)', () => { + let db, svc, userIdA; + + before(async () => { + await setup(); + db = require('../../src/db').getDb(); + svc = require('../../src/services/characterService'); + + // Create a user and character directly + const uid = Number(db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)').run('allocsvc', 'x', NOW).lastInsertRowid); + const { id } = svc.createCharacter(uid, 'SvcHero', 'warrior'); + userIdA = uid; + + // Give the character 3 unspent points + db.prepare('UPDATE characters SET unspent_points = 3 WHERE id = ?').run(id); + }); + + after(async () => { + const { resetNow } = require('../../src/utils/time'); + resetNow(); + await teardown(); + cleanup(); + }); + + it('spend 2 of 3 points on STR → str +2, unspent_points 1', () => { + const charBefore = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userIdA); + assert.equal(charBefore.unspent_points, 3); + assert.equal(charBefore.str, 8); // warrior base + + const result = svc.allocatePoints(userIdA, { str: 2 }); + + assert.equal(result.character.str, 10); // 8 + 2 + assert.equal(result.character.unspent_points, 1); // 3 - 2 + // Warrior attack = 5 + str*2 + level = 5 + 10*2 + 1 = 26 + assert.equal(result.derived.attack, 26); + }); + + it('spending 4 of 3 → NOT_ENOUGH_POINTS, nothing changed', () => { + const charBefore = db.prepare('SELECT str, unspent_points FROM characters WHERE user_id = ?').get(userIdA); + const oldStr = charBefore.str; + const oldPts = charBefore.unspent_points; + + try { + svc.allocatePoints(userIdA, { str: 4 }); + assert.fail('Should have thrown'); + } catch (err) { + assert.equal(err.code, 'NOT_ENOUGH_POINTS'); + } + + // Verify nothing changed + const charAfter = db.prepare('SELECT str, unspent_points FROM characters WHERE user_id = ?').get(userIdA); + assert.equal(charAfter.str, oldStr); + assert.equal(charAfter.unspent_points, oldPts); + }); + + it('spend all 3 evenly (1 each) → unspent_points 0, all stats increased', () => { + // First give points back + db.prepare('UPDATE characters SET unspent_points = 3, str = 8, agi = 5, int = 5, vit = 8 WHERE user_id = ?').run(userIdA); + const charBefore = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userIdA); + assert.equal(charBefore.unspent_points, 3); + + const result = svc.allocatePoints(userIdA, { str: 1, agi: 1, vit: 1 }); + + assert.equal(result.character.unspent_points, 0); + assert.equal(result.character.str, 9); + assert.equal(result.character.agi, 6); + assert.equal(result.character.vit, 9); + }); + + it('negative values throw INVALID_FIELD', () => { + try { + svc.allocatePoints(userIdA, { str: -1 }); + assert.fail('Should have thrown'); + } catch (err) { + assert.equal(err.code, 'INVALID_FIELD'); + } + }); + + it('float values throw INVALID_FIELD', () => { + try { + svc.allocatePoints(userIdA, { str: 1.5 }); + assert.fail('Should have thrown'); + } catch (err) { + assert.equal(err.code, 'INVALID_FIELD'); + } + }); + + it('unknown keys throw INVALID_FIELD', () => { + try { + svc.allocatePoints(userIdA, { str: 1, luck: 1 }); + assert.fail('Should have thrown'); + } catch (err) { + assert.equal(err.code, 'INVALID_FIELD'); + } + }); +}); diff --git a/tests/integration/step-10.8.test.js b/tests/integration/step-10.8.test.js new file mode 100644 index 0000000..9dccd76 --- /dev/null +++ b/tests/integration/step-10.8.test.js @@ -0,0 +1,148 @@ +// Integration tests for POST /api/character/allocate endpoint. +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, cleanup } = require('../helpers'); + +describe('POST /api/character/allocate (integration)', () => { + let baseUrl; + let loginCookie; + + before(async () => { + baseUrl = await setup(); + }); + + after(async () => { + const { resetNow } = require('../../src/utils/time'); + resetNow(); + await teardown(); + cleanup(); + }); + + function allocate(body) { + return fetch(`${baseUrl}/api/character/allocate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: loginCookie }, + body: JSON.stringify(body), + }); + } + + function registerAndLogin(username) { + return fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password: 'password123' }), + }).then(() => fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password: 'password123' }), + })); + } + + function createHero(cookie, name) { + return fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ name, class: 'warrior' }), + }); + } + + it('spend 2 of 3 points on STR → str +2, unspent_points 1, derived attack increased', async () => { + const res = await registerAndLogin('alloc1'); + loginCookie = res.headers.get('set-cookie'); + await createHero(loginCookie, 'Allocher1'); + + // Use the service directly to set up test state (bypasses HTTP/session layer) + const db = require('../../src/db').getDb(); + const svc = require('../../src/services/characterService'); + const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('alloc1').id); + const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId); + db.prepare('UPDATE characters SET unspent_points = 3 WHERE id = ?').run(char.id); + + const allocateRes = await allocate({ str: 2 }); + assert.equal(allocateRes.status, 200); + + const body = await allocateRes.json(); + assert.equal(body.ok, true); + assert.equal(body.data.character.str, 10); // warrior base 8 + 2 + assert.equal(body.data.character.unspent_points, 1); + // Warrior attack = 5 + str*2 + level = 5 + 10*2 + 1 = 26 + assert.equal(body.data.derived.attack, 26); + }); + + it('spending 4 of 3 → 422 NOT_ENOUGH_POINTS, nothing changed', async () => { + const res = await registerAndLogin('alloc2'); + loginCookie = res.headers.get('set-cookie'); + await createHero(loginCookie, 'Allocher2'); + + const db = require('../../src/db').getDb(); + const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('alloc2').id); + const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId); + db.prepare('UPDATE characters SET unspent_points = 3 WHERE id = ?').run(char.id); + + const allocateRes = await allocate({ str: 4 }); + assert.equal(allocateRes.status, 422); + + const body = await allocateRes.json(); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'NOT_ENOUGH_POINTS'); + + // Verify nothing changed in DB + const refreshed = db.prepare('SELECT str, unspent_points FROM characters WHERE id = ?').get(char.id); + assert.equal(refreshed.str, 8); // warrior base unchanged + assert.equal(refreshed.unspent_points, 3); // points unchanged + }); + + it('negative values → 400', async () => { + const res = await registerAndLogin('alloc3'); + loginCookie = res.headers.get('set-cookie'); + await createHero(loginCookie, 'Allocher3'); + + const allocateRes = await allocate({ str: -1 }); + assert.equal(allocateRes.status, 400); + const body = await allocateRes.json(); + assert.equal(body.ok, false); + }); + + it('float values → 400', async () => { + const res = await registerAndLogin('alloc4'); + loginCookie = res.headers.get('set-cookie'); + await createHero(loginCookie, 'Allocher4'); + + const allocateRes = await allocate({ str: 1.5 }); + assert.equal(allocateRes.status, 400); + const body = await allocateRes.json(); + assert.equal(body.ok, false); + }); + + it('unknown keys → 400', async () => { + const res = await registerAndLogin('alloc5'); + loginCookie = res.headers.get('set-cookie'); + await createHero(loginCookie, 'Allocher5'); + + const allocateRes = await allocate({ str: 1, luck: 1 }); + assert.equal(allocateRes.status, 400); + const body = await allocateRes.json(); + assert.equal(body.ok, false); + }); + + it('another user cannot affect user A\'s character', async () => { + const db = require('../../src/db').getDb(); + + // Get user A's character (from first test) + const charA = db.prepare('SELECT id, str FROM characters WHERE name = ?').get('Allocher1'); + const oldStr = charA.str; + + // User B creates hero and tries to allocate + const res = await registerAndLogin('alloc_b'); + loginCookie = res.headers.get('set-cookie'); + await createHero(loginCookie, 'AllocherB'); + + // User B allocates on their own character (which has 0 unspent points) + const allocateRes = await allocate({ str: 1 }); + assert.equal(allocateRes.status, 422); // NOT_ENOUGH_POINTS for user B + + // User A's character is untouched + const refreshed = db.prepare('SELECT str FROM characters WHERE id = ?').get(charA.id); + assert.equal(refreshed.str, oldStr); + }); +}); diff --git a/tests/unit/rewards-leveling.test.js b/tests/unit/rewards-leveling.test.js index d537af4..3c99cbd 100644 --- a/tests/unit/rewards-leveling.test.js +++ b/tests/unit/rewards-leveling.test.js @@ -151,6 +151,6 @@ describe('leveling', () => { }); it('allocatePoints — non-integer throws', () => { - assert.throws(() => allocatePoints(8, 4, 2, 8, 3, { str: 1.5 }), /positive integer/); + assert.throws(() => allocatePoints(8, 4, 2, 8, 3, { str: 1.5 }), /non-negative integer/); }); });