Split server/index.js into one file per area
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin); index.js keeps setup only. A smoke test walks 90 API calls against the real server and compares every status with the pre-split behaviour. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
31ac6b9179
commit
9893883b19
12 files changed
+1337
-1040
No files matched your search
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment node
|
||||
// Starts the real server (server/index.js) on a spare port with a throwaway data folder and walks the whole API
|
||||
// the way the app does: accounts, studies, synced reader data, sharing, co-teaching, admin. It records the status of
|
||||
// every call, so reorganising the server's files can't quietly change what any route answers.
|
||||
import { afterAll, beforeAll, expect, test } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createServer } from 'net';
|
||||
|
||||
const ADMIN = 'admin@x.test';
|
||||
let proc; let dir; let base;
|
||||
const jars = {}; // who -> cookie
|
||||
const log = [];
|
||||
|
||||
const freePort = () => new Promise((resolve) => { const s = createServer(); s.listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
|
||||
|
||||
async function call(who, method, path, body, headers = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method, headers: { 'Content-Type': 'application/json', ...(jars[who] ? { Cookie: jars[who] } : {}), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual',
|
||||
});
|
||||
const set = res.headers.get('set-cookie');
|
||||
if (set) jars[who] = set.split(';')[0];
|
||||
const text = await res.text();
|
||||
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
|
||||
log.push(`${who} ${method} ${path.replace(/[0-9a-f]{20,}/g, ':token').replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, ':id').replace(/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}/g, ':code')} ${res.status}`);
|
||||
return { status: res.status, json, text };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'smoke-'));
|
||||
const port = await freePort();
|
||||
base = `http://127.0.0.1:${port}`;
|
||||
proc = spawn('node', ['server/index.js'], {
|
||||
env: { ...process.env, PORT: String(port), DATA_DIR: dir, ADMIN_EMAIL: ADMIN, SESSION_SECRET: 'smoke', BACKUP_DISABLED: '1', NODE_ENV: 'development', IQBIBLE_API_KEY: '' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
try { if ((await fetch(`${base}/api/health`)).ok) return; } catch { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error('server did not start');
|
||||
}, 20000);
|
||||
afterAll(() => { proc?.kill(); rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
const study = (id, title = 'Romans') => ({ id, title, translation: 'BSB', lastEdited: 1, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }] });
|
||||
|
||||
test('the whole API answers the way it always has', async () => {
|
||||
// ---- public ----
|
||||
expect((await call('anon', 'GET', '/api/health')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', '/api/auth/signup-mode')).json.mode).toBe('open');
|
||||
expect((await call('anon', 'GET', '/api/auth/me')).status).toBe(401);
|
||||
expect((await call('anon', 'GET', '/api/projects')).status).toBe(401);
|
||||
expect((await call('anon', 'GET', '/api/admin/users')).status).toBe(401);
|
||||
|
||||
// ---- accounts ----
|
||||
expect((await call('admin', 'POST', '/api/auth/register', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||
expect((await call('sam', 'POST', '/api/auth/register', { email: 'sam@x.test', password: 'correct-horse-2' })).status).toBe(200);
|
||||
expect((await call('anon', 'POST', '/api/auth/register', { email: 'bad', password: 'x' })).status).toBe(400);
|
||||
expect((await call('anon', 'POST', '/api/auth/login', { email: ADMIN, password: 'wrong-password' })).status).toBe(401);
|
||||
expect((await call('admin', 'POST', '/api/auth/login', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||
const me = await call('admin', 'GET', '/api/auth/me');
|
||||
expect(me.json).toMatchObject({ email: ADMIN, isAdmin: true, totpEnabled: false, passkeyCount: 0 });
|
||||
expect((await call('sam', 'GET', '/api/auth/me')).json.isAdmin).toBe(false);
|
||||
expect((await call('anon', 'POST', '/api/auth/forgot', { email: 'sam@x.test' })).json.ok).toBe(true);
|
||||
expect((await call('anon', 'POST', '/api/auth/reset', { email: 'sam@x.test', code: 'nope', newPassword: 'another-pass-3' })).status).toBe(400);
|
||||
expect((await call('admin', 'PATCH', '/api/auth/profile', { podcastName: 'My Show' })).status).toBe(200);
|
||||
expect((await call('admin', 'PATCH', '/api/auth/reminder-settings', { reminderEnabled: true, ntfyTopic: '', reminderTime: '08:00' })).status).toBe(400);
|
||||
expect((await call('admin', 'POST', '/api/auth/reminder-test', { ntfyTopic: 'bad topic!' })).status).toBe(400);
|
||||
expect((await call('admin', 'POST', '/api/auth/change-password', { currentPassword: 'nope', newPassword: 'brand-new-pass-4' })).status).toBe(401);
|
||||
expect((await call('admin', 'POST', '/api/auth/mfa/setup')).json.secret).toBeTruthy();
|
||||
expect((await call('admin', 'POST', '/api/auth/mfa/enable', { token: '000000' })).status).toBe(401);
|
||||
expect((await call('anon', 'POST', '/api/auth/mfa/verify', { token: '000000' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/account/export')).json.app).toBeTruthy();
|
||||
|
||||
// ---- studies ----
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', study('s1'))).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', study('other'))).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', { id: 's1' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/projects')).json).toHaveLength(1);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1')).json.title).toBe('Romans');
|
||||
expect((await call('sam', 'GET', '/api/projects/s1')).status).toBe(404);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', { ...study('s1', 'Romans 8'), lastEdited: 2 }, { 'X-Base-Edited': '1' })).status).toBe(409); // another device saved since
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/versions')).json.versions).toBeInstanceOf(Array);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/versions/999')).status).toBe(404);
|
||||
expect((await call('admin', 'POST', '/api/projects/s1/versions/999/restore')).status).toBe(404);
|
||||
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(404);
|
||||
|
||||
// ---- synced reader data ----
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: [{ p: 1 }] })).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: 'no' })).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROMANS/8', { strokes: [] })).status).toBe(400);
|
||||
expect(Object.keys((await call('admin', 'GET', '/api/reader/ink')).json)).toHaveLength(1);
|
||||
expect((await call('admin', 'PUT', '/api/prefs', { prefs: { theme: { v: 'dark', at: 5 } } })).json.prefs.theme.v).toBe('dark');
|
||||
expect((await call('admin', 'GET', '/api/prefs')).json.prefs.theme.v).toBe('dark');
|
||||
expect((await call('admin', 'PUT', '/api/reader/marks', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] })).json.ok).toBe(true);
|
||||
expect((await call('admin', 'GET', '/api/reader/marks')).json.bookmarks['JHN-3-16']).toBe('#fef08a');
|
||||
expect((await call('admin', 'PUT', '/api/reader/marks', 'x')).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/prayers', { prayers: [{ id: 'p1', text: 'Peace', createdAt: 1, updatedAt: 2 }] })).json.prayers).toHaveLength(1);
|
||||
expect((await call('admin', 'GET', '/api/prayers')).json.prayers).toHaveLength(1);
|
||||
expect((await call('admin', 'PUT', '/api/reader/history', { 'JHN-3': 1 })).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/reader/history')).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-10-01'] })).json.ok).toBe(true);
|
||||
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: 'no' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/reading-plans')).json.progress).toHaveLength(1);
|
||||
|
||||
// ---- other areas mounted on the app ----
|
||||
expect((await call('admin', 'GET', '/api/iq/status')).json.available).toBe(false);
|
||||
expect((await call('admin', 'GET', '/api/iq/nope')).status).toBe(404);
|
||||
expect((await call('admin', 'GET', '/api/passkeys')).json).toEqual([]);
|
||||
expect((await call('admin', 'GET', '/api/sessions')).json).toHaveLength(1);
|
||||
expect((await call('admin', 'POST', '/api/auth/passkey/options')).json.challenge).toBeTruthy();
|
||||
expect((await call('anon', 'GET', '/api/bible/nope')).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// ---- sharing ----
|
||||
const share = await call('admin', 'POST', '/api/projects/s1/share', {});
|
||||
expect(share.status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/share')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', `/api/share/${share.json.shareToken}`)).status).toBe(200);
|
||||
expect((await call('anon', 'GET', '/api/share/doesnotexist')).status).toBe(404);
|
||||
const outline = await call('admin', 'POST', '/api/projects/s1/outline-share', {});
|
||||
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(404);
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1/share')).status).toBe(200);
|
||||
const invite = await call('admin', 'POST', '/api/projects/s1/collaborators/invite');
|
||||
expect(invite.status).toBe(200);
|
||||
expect((await call('sam', 'POST', `/api/join/${invite.json.token}`)).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/collaborators')).status).toBe(200);
|
||||
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', `/api/projects/s1/collaborators/${me.json.id}`)).status).toBe(200);
|
||||
|
||||
// ---- admin ----
|
||||
expect((await call('sam', 'GET', '/api/admin/users')).status).toBe(403);
|
||||
expect((await call('admin', 'GET', '/api/admin/users')).json).toHaveLength(2);
|
||||
expect((await call('admin', 'GET', '/api/admin/projects')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/projects/s1')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/signup')).json.mode).toBe('open');
|
||||
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'nonsense' })).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'invite' })).status).toBe(200);
|
||||
const code = await call('admin', 'POST', '/api/admin/invites', { note: 'x', days: 7 });
|
||||
expect(code.status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', `/api/admin/invites/${encodeURIComponent(code.json.code ?? 'none')}`)).status).toBeGreaterThanOrEqual(200);
|
||||
const health = await call('admin', 'GET', '/api/admin/health');
|
||||
expect(health.json).toMatchObject({ iqConfigured: false });
|
||||
expect(health.json.signinFailures.length).toBeGreaterThan(0); // the wrong password above
|
||||
expect((await call('admin', 'POST', '/api/admin/backups')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/backups/nope.db')).status).toBe(404);
|
||||
expect((await call('admin', 'POST', '/api/admin/backups/projects-2000-01-01-00-00.db/restore', { confirm: 'x' })).status).toBe(404);
|
||||
const samId = (await call('sam', 'GET', '/api/auth/me')).json.id;
|
||||
expect((await call('admin', 'POST', `/api/admin/users/${samId}/reset-password`)).json.temporaryPassword).toBeTruthy();
|
||||
expect((await call('sam', 'GET', '/api/auth/me')).status).toBe(401); // signed out everywhere
|
||||
expect((await call('admin', 'DELETE', `/api/admin/users/${samId}`)).status).toBe(200);
|
||||
|
||||
// ---- finishing ----
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1')).status).toBe(200);
|
||||
expect((await call('admin', 'POST', '/api/auth/logout')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/auth/me')).status).toBe(401);
|
||||
|
||||
// Every call and its status, in order. Run with UPDATE_SMOKE=1 to rewrite it after a deliberate API change.
|
||||
const file = new URL('./routes.smoke.expected.txt', import.meta.url);
|
||||
if (process.env.UPDATE_SMOKE === '1') writeFileSync(file, `${log.join('\n')}\n`);
|
||||
expect(`${log.join('\n')}\n`).toBe(readFileSync(file, 'utf8'));
|
||||
}, 60000);
|
||||
Reference in new issue
Block a user