step 10.8: wip (not ticked)
This commit is contained in:
+17
-18
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+24
-2
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user