69 lines
5.5 KiB
TypeScript
69 lines
5.5 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Database } from 'bun:sqlite';
|
|
import { hashToken } from '../src/auth';
|
|
|
|
const directory = mkdtempSync(join(tmpdir(), 'minabot-api-smoke-'));
|
|
const databasePath = join(directory, 'test.sqlite');
|
|
const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() });
|
|
const port = probe.port!; probe.stop(true);
|
|
const origin = `http://127.0.0.1:${port}`;
|
|
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
|
|
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
|
const token = 's'.repeat(43);
|
|
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
|
async function boot() {
|
|
processHandle = Bun.spawn([process.execPath, 'scripts/start.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
try { if ((await fetch(`${origin}/api/health`)).ok) return; } catch { /* Wait for startup. */ }
|
|
if (processHandle.exitCode !== null) throw new Error(`Production server exited with ${processHandle.exitCode}`);
|
|
await Bun.sleep(50);
|
|
}
|
|
throw new Error('Production server did not become healthy');
|
|
}
|
|
async function stop() { if (processHandle) { processHandle.kill(); await processHandle.exited; processHandle = undefined; } }
|
|
async function request(path: string, method = 'GET', body?: unknown, status = 200) {
|
|
const response = await fetch(`${origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${token}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
const data = await response.json();
|
|
if (response.status !== status) throw new Error(`${method} ${path}: ${response.status}: ${JSON.stringify(data)}`);
|
|
return data as any;
|
|
}
|
|
function check(value: unknown, message: string): asserts value { if (!value) throw new Error(message); }
|
|
try {
|
|
const migration = Bun.spawn([process.execPath, 'scripts/migrate.ts'], { env, stdout: 'pipe', stderr: 'pipe' });
|
|
check(await migration.exited === 0, 'Fresh production migrations failed');
|
|
const sqlite = new Database(databasePath); const now = Date.now();
|
|
sqlite.query('INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run('smoke', 'smoke', 'Smoke', 'UTC', now, now);
|
|
sqlite.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(hashToken(token), 'smoke', now, now + 3600000);
|
|
sqlite.close();
|
|
await boot();
|
|
const date = (await request('/today')).date;
|
|
const h = await request('/habits', 'POST', { name: 'HTTP hydration', method: 'count', target: 8 }, 201);
|
|
const schedule = { type: 'weekdays', days: [new Date(`${date}T00:00:00Z`).getUTCDay()] };
|
|
const t = await request('/habits', 'POST', { name: 'HTTP tasks', method: 'tasks', tasks: [{ name: 'Check', schedule }] }, 201);
|
|
const renamedTask = await request(`/habits/${t.id}/tasks/${t.tasks[0].id}`, 'PATCH', { name: 'Renamed task' });
|
|
check(JSON.stringify(renamedTask.schedule) === JSON.stringify(schedule), 'Task rename reset recurrence');
|
|
await Promise.all([request(`/habits/${h.id}`, 'PATCH', { name: 'Concurrent hydration' }), request(`/habits/${h.id}`, 'PATCH', { unit: 'glasses' })]);
|
|
const updated = await request(`/habits/${h.id}`);
|
|
check(updated.name === 'Concurrent hydration' && updated.unit === 'glasses', 'Overlapping patches lost unrelated settings');
|
|
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 7 });
|
|
await request(`/habits/${t.id}/days/${date}/tasks/${t.tasks[0].id}`, 'PUT', { done: true });
|
|
const c = await request('/charts', 'POST', { name: 'HTTP combined', habitIds: [h.id, t.id], settings: { mainColor: '#ff0000', shadeCount: 8 } }, 201);
|
|
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Partial habit incorrectly contributed to combined score');
|
|
const renamedChart = await request(`/charts/${c.id}`, 'PATCH', { name: 'Renamed chart' });
|
|
check(renamedChart.settings.mainColor === '#ff0000' && renamedChart.settings.shadeCount === 8, 'Chart rename reset settings');
|
|
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
|
|
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
|
|
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
|
|
await stop(); await boot();
|
|
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
|
|
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
|
|
await request(`/habits/${h.id}/days/${date}/progress`, 'PUT', { count: 8 });
|
|
const result = await request(`/charts/${c.id}/calendar?from=${date}&to=${date}`);
|
|
check(result.days[0].ratio === 1, 'Correction did not update combined calendar');
|
|
check((await fetch(`${origin}/api/habits`)).status === 401, 'HTTP authentication was bypassed');
|
|
check((await fetch(`${origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${token}`, Origin: 'https://evil.example', 'Content-Type': 'application/json' }, body: '{}' })).status === 403, 'HTTP Origin enforcement was bypassed');
|
|
console.log('Production HTTP smoke passed: migrations, count/task logs, combined scores, correction, PATCH preservation, overlapping writes, archive/restore, authentication, CSRF, and persistence across restart.');
|
|
} finally { await stop(); rmSync(directory, { recursive: true, force: true }); }
|