M1: backend (Express+ws+better-sqlite3) + integration tests

- server/db.js: SQLite schema mirroring Firestore doc tree
- server/handlers.js: action -> shared turn fn -> tx persist -> broadcast
- server/index.js: REST endpoints + WebSocket real-time push
- server/server.test.js: 7 integration tests (REST CRUD + combat flow)
- --forceExit for jest (open WS handles)

Backend boots, serves state, persists to SQLite.
This commit is contained in:
david raistrick
2026-06-28 17:01:53 -04:00
parent e06adaa081
commit 0e76fb2fc7
6 changed files with 821 additions and 1 deletions
+113
View File
@@ -0,0 +1,113 @@
// Integration smoke for server. Spin on random port, hit REST, check WS broadcast.
'use strict';
const http = require('http');
const { createServer } = require('./index');
let BASE;
let handle;
beforeEach(async () => {
handle = createServer({ dbPath: ':memory:' });
await new Promise(r => handle.server.listen(0, r));
const addr = handle.server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
await new Promise(r => handle.close(r));
});
async function req(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, json };
}
describe('server REST', () => {
test('health', async () => {
const { status, json } = await req('GET', '/health');
expect(status).toBe(200);
expect(json.ok).toBe(true);
});
test('campaign create + list', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'Test', backgroundUrl: '' });
expect(c.name).toBe('Test');
const { json: list } = await req('GET', '/api/campaigns');
expect(list).toHaveLength(1);
expect(list[0].id).toBe(c.id);
});
test('encounter create + add monster + start', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
expect(e.participants).toEqual([]);
const { json: addRes } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: 'Goblin', maxHp: 7, initMod: 2, isNpc: false,
});
expect(addRes.encounter.participants).toHaveLength(1);
expect(addRes.encounter.participants[0].name).toBe('Goblin');
expect(addRes.roll.total).toBeGreaterThan(2);
const { json: started } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
expect(started.isStarted).toBe(true);
expect(started.round).toBe(1);
expect(started.currentTurnParticipantId).toBe(addRes.encounter.participants[0].id);
});
test('next turn advances', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
const ids = [];
for (let i = 0; i < 3; i++) {
const { json: r } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: `M${i}`, maxHp: 10, initMod: i,
});
ids.push(r.encounter.participants[r.encounter.participants.length - 1].id);
}
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
const { json: t1 } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/nextTurn`);
expect(t1.currentTurnParticipantId).not.toBe(t1.turnOrderIds[0]);
});
test('damage to 0 deactivates', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
const { json: r } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: 'Orc', maxHp: 5, initMod: 0,
});
const pid = r.encounter.participants[0].id;
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
const { json: after } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants/${pid}/hp`, {
changeType: 'damage', amount: 5,
});
expect(after.participants[0].currentHp).toBe(0);
expect(after.participants[0].isActive).toBe(false);
});
test('error: start with no participants', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
const { status, json } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
expect(status).toBe(400);
expect(json.error).toMatch(/participants/i);
});
test('logs recorded on actions', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: 'Goblin', maxHp: 7, initMod: 2,
});
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
const { json: logs } = await req('GET', '/api/logs');
expect(logs.length).toBeGreaterThanOrEqual(2);
expect(logs.some(l => /Combat started/.test(l.message))).toBe(true);
});
});