mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
test(api): add temporary pocketbase harness for api tests
`pnpm test:api` starts an isolated PocketBase with project migrations and hooks in a temp dir, provides SDK client helpers and cleans up after the run, on failure and on SIGINT/SIGTERM. The superuser is generated per instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EvmtStx737hu8gXe15e55W
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b7b32a0457
commit
5d5b2af318
@@ -0,0 +1,11 @@
|
||||
import type { TestProject } from 'vitest/node';
|
||||
import { startPocketBase } from '../support/pocketbase-server';
|
||||
import '../support/clients';
|
||||
|
||||
export default async function setup(project: TestProject) {
|
||||
const { url, dataDir, superuser, stop } = await startPocketBase();
|
||||
project.provide('pbUrl', url);
|
||||
project.provide('pbDataDir', dataDir);
|
||||
project.provide('pbSuperuser', superuser);
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { existsSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, inject, it, vi } from 'vitest';
|
||||
import { asGuest, asSuperuser, asUser, createUser } from '../support/clients';
|
||||
import { type PocketBaseServer, startPocketBase } from '../support/pocketbase-server';
|
||||
|
||||
const health = (url: string) => fetch(`${url}/api/health`);
|
||||
const holdFixture = path.resolve('tests/support/fixtures/hold-pocketbase.ts');
|
||||
|
||||
describe('стенд API-тестов', () => {
|
||||
it('отвечает 200 на GET /api/health', async () => {
|
||||
const response = await health(inject('pbUrl'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('asSuperuser() авторизован как суперпользователь', async () => {
|
||||
const pb = await asSuperuser();
|
||||
|
||||
expect(pb.authStore.isValid).toBe(true);
|
||||
expect(pb.authStore.isSuperuser).toBe(true);
|
||||
});
|
||||
|
||||
it('asGuest() не авторизован', () => {
|
||||
const pb = asGuest();
|
||||
|
||||
expect(pb.authStore.isValid).toBe(false);
|
||||
expect(pb.authStore.record).toBeNull();
|
||||
});
|
||||
|
||||
it('createUser() каждый раз создаёт нового пользователя с уникальным email', async () => {
|
||||
const a = await createUser();
|
||||
const b = await createUser();
|
||||
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.email).not.toBe(b.email);
|
||||
expect(a.email).toMatch(/^user-.+@test\.local$/);
|
||||
expect(b.email).toMatch(/^user-.+@test\.local$/);
|
||||
});
|
||||
|
||||
it('createUser(overrides) применяет переданные поля', async () => {
|
||||
const user = await createUser({ name: 'Иван' });
|
||||
|
||||
const record = await (await asSuperuser()).collection('users').getOne(user.id);
|
||||
|
||||
expect(record['name']).toBe('Иван');
|
||||
});
|
||||
|
||||
it('asUser(user) авторизован именно этим пользователем', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const pb = await asUser(user);
|
||||
|
||||
expect(pb.authStore.isValid).toBe(true);
|
||||
expect(pb.authStore.isSuperuser).toBe(false);
|
||||
expect(pb.authStore.record?.id).toBe(user.id);
|
||||
expect(pb.authStore.record?.['email']).toBe(user.email);
|
||||
});
|
||||
|
||||
it('у каждого хелпера свой экземпляр SDK', async () => {
|
||||
const superuser = await asSuperuser();
|
||||
const guest = asGuest();
|
||||
|
||||
expect(guest).not.toBe(superuser);
|
||||
expect(guest).not.toBe(asGuest());
|
||||
expect(guest.authStore.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('работает на отдельном временном PocketBase, а не на pocketbase/pb_data', async () => {
|
||||
const url = inject('pbUrl');
|
||||
const dataDir = inject('pbDataDir');
|
||||
|
||||
expect(url).not.toBe(process.env['PB_URL'] ?? 'http://127.0.0.1:8090');
|
||||
expect(new URL(url).hostname).toBe('127.0.0.1');
|
||||
expect(path.resolve(dataDir)).not.toBe(path.resolve('pocketbase/pb_data'));
|
||||
expect(path.resolve(dataDir).startsWith(path.resolve(os.tmpdir()) + path.sep)).toBe(true);
|
||||
expect(existsSync(dataDir)).toBe(true);
|
||||
|
||||
const user = await createUser();
|
||||
const record = await (await asSuperuser()).collection('users').getOne(user.id);
|
||||
expect(record.id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPocketBase', { timeout: 30_000 }, () => {
|
||||
it('stop() останавливает процесс и удаляет временную папку', async () => {
|
||||
const server = await startPocketBase();
|
||||
|
||||
try {
|
||||
expect((await health(server.url)).status).toBe(200);
|
||||
expect(existsSync(server.dataDir)).toBe(true);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
|
||||
await expect(health(server.url)).rejects.toThrow();
|
||||
expect(existsSync(server.dataDir)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['SIGINT', 'SIGTERM'] as const)(
|
||||
'по %s процесс-владелец убирает за собой PocketBase и папку',
|
||||
async (signal) => {
|
||||
// Фикстура поднимает PocketBase, печатает в stdout JSON с url и dataDir и ждёт сигнала.
|
||||
// Флаг подавляет предупреждение MODULE_TYPELESS_PACKAGE_JSON для .ts-файла.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--disable-warning=MODULE_TYPELESS_PACKAGE_JSON', holdFixture],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
// Если фикстура упала до вывода, тест падает сразу, а не ждёт таймаута.
|
||||
const [chunk] = await Promise.race([
|
||||
once(child.stdout, 'data'),
|
||||
once(child, 'exit').then(() => {
|
||||
throw new Error('фикстура завершилась, не запустив PocketBase');
|
||||
}),
|
||||
]);
|
||||
const { url, dataDir } = JSON.parse(String(chunk)) as {
|
||||
url: string;
|
||||
dataDir: string;
|
||||
};
|
||||
expect((await health(url)).status).toBe(200);
|
||||
|
||||
const exited = once(child, 'exit');
|
||||
child.kill(signal);
|
||||
await exited;
|
||||
|
||||
await expect(health(url)).rejects.toThrow();
|
||||
expect(existsSync(dataDir)).toBe(false);
|
||||
} finally {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('повторный stop() не бросает ошибку', async () => {
|
||||
const server = await startPocketBase();
|
||||
await server.stop();
|
||||
|
||||
await expect(server.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('параллельные запуски получают разные порты и папки', async () => {
|
||||
// allSettled, а не all: если один запуск упал, второй всё равно нужно остановить в finally.
|
||||
const results = await Promise.allSettled([startPocketBase(), startPocketBase()]);
|
||||
const servers = results
|
||||
.filter((r): r is PromiseFulfilledResult<PocketBaseServer> => r.status === 'fulfilled')
|
||||
.map((r) => r.value);
|
||||
|
||||
try {
|
||||
expect(servers).toHaveLength(2);
|
||||
const [a, b] = servers;
|
||||
expect(a.url).not.toBe(b.url);
|
||||
expect(a.dataDir).not.toBe(b.dataDir);
|
||||
expect(a.superuser.email).not.toBe(b.superuser.email);
|
||||
expect((await health(a.url)).status).toBe(200);
|
||||
expect((await health(b.url)).status).toBe(200);
|
||||
} finally {
|
||||
await Promise.all(servers.map((s) => s.stop()));
|
||||
}
|
||||
});
|
||||
|
||||
it('без бинарника pocketbase в PATH падает с понятной ошибкой, а не по таймауту', async () => {
|
||||
vi.stubEnv('PATH', '');
|
||||
|
||||
try {
|
||||
await expect(startPocketBase()).rejects.toThrow(/pocketbase/i);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// PocketBase SDK clients for tests; each helper returns its own instance with an in-memory auth store.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import PocketBase, { BaseAuthStore } from 'pocketbase';
|
||||
import { inject } from 'vitest';
|
||||
|
||||
declare module 'vitest' {
|
||||
export interface ProvidedContext {
|
||||
pbUrl: string;
|
||||
pbDataDir: string;
|
||||
pbSuperuser: { email: string; password: string };
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestUser {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function asGuest(): PocketBase {
|
||||
return new PocketBase(inject('pbUrl'), new BaseAuthStore());
|
||||
}
|
||||
|
||||
export async function asSuperuser(): Promise<PocketBase> {
|
||||
const { email, password } = inject('pbSuperuser');
|
||||
const pb = asGuest();
|
||||
await pb.collection('_superusers').authWithPassword(email, password);
|
||||
return pb;
|
||||
}
|
||||
|
||||
export async function createUser(overrides: Record<string, unknown> = {}): Promise<TestUser> {
|
||||
const data = { email: `user-${randomUUID()}@test.local`, password: randomUUID(), ...overrides };
|
||||
const record = await (
|
||||
await asSuperuser()
|
||||
)
|
||||
.collection('users')
|
||||
.create({ ...data, passwordConfirm: data.password });
|
||||
return { id: record.id, email: String(data.email), password: String(data.password) };
|
||||
}
|
||||
|
||||
export async function asUser(user: TestUser): Promise<PocketBase> {
|
||||
const pb = asGuest();
|
||||
await pb.collection('users').authWithPassword(user.email, user.password);
|
||||
return pb;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Starts PocketBase and holds it until the process gets a signal; used to test cleanup on Ctrl+C.
|
||||
import { startPocketBase } from '../pocketbase-server.ts';
|
||||
|
||||
const { url, dataDir } = await startPocketBase();
|
||||
console.log(JSON.stringify({ url, dataDir }));
|
||||
setInterval(() => undefined, 1 << 30);
|
||||
@@ -0,0 +1,145 @@
|
||||
// Запускает временный PocketBase с миграциями и хуками проекта (см. docs/testing.md).
|
||||
// Выполняется под чистым Node (tests/support/fixtures/hold-pocketbase.ts): только импорты node:* и стираемый TypeScript.
|
||||
import { type ChildProcess, execFile, spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { type AddressInfo, createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const START_TIMEOUT_MS = 10_000;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const SIGNALS = ['SIGINT', 'SIGTERM'] as const;
|
||||
|
||||
/** Запущенный временный экземпляр PocketBase. */
|
||||
export interface PocketBaseServer {
|
||||
url: string;
|
||||
dataDir: string;
|
||||
superuser: { email: string; password: string };
|
||||
/** Останавливает процесс и удаляет `dataDir`. Повторный вызов безопасен. */
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает одноразовый PocketBase на свободном порту и ждёт, пока он ответит на `/api/health`.
|
||||
*
|
||||
* Процесс и каталог данных удаляются при `stop()`, по `SIGINT`/`SIGTERM`, при выходе
|
||||
* и при ошибке запуска.
|
||||
*
|
||||
* @throws Если `pocketbase` нет в PATH, не создался суперпользователь или сервер не ответил
|
||||
* за {@link START_TIMEOUT_MS}. В сообщение ошибки включён вывод PocketBase.
|
||||
*/
|
||||
export async function startPocketBase(): Promise<PocketBaseServer> {
|
||||
const superuser = { email: `superuser-${randomUUID()}@test.local`, password: randomUUID() };
|
||||
const dataDir = await mkdtemp(join(tmpdir(), 'pchelpcrm-pb-'));
|
||||
let child: ChildProcess | undefined;
|
||||
let stopping: Promise<void> | undefined;
|
||||
|
||||
const onSignal = (signal: NodeJS.Signals) => {
|
||||
void stop().then(() => process.kill(process.pid, signal));
|
||||
};
|
||||
// Обработчик `exit` не может ждать, поэтому здесь только синхронные вызовы.
|
||||
const onExit = () => {
|
||||
child?.kill('SIGKILL');
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
};
|
||||
const stopProcess = async () => {
|
||||
if (child?.pid !== undefined && child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, 'exit');
|
||||
child.kill('SIGTERM');
|
||||
const timer = setTimeout(() => child?.kill('SIGKILL'), STOP_TIMEOUT_MS);
|
||||
await exited;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
// Снимаются последними: сигнал во время очистки не должен убить владельца раньше её конца.
|
||||
for (const signal of SIGNALS) process.off(signal, onSignal);
|
||||
process.off('exit', onExit);
|
||||
};
|
||||
// Очистка выполняется один раз: повторные вызовы получают тот же промис.
|
||||
const stop = () => (stopping ??= stopProcess());
|
||||
|
||||
for (const signal of SIGNALS) process.on(signal, onSignal);
|
||||
process.on('exit', onExit);
|
||||
|
||||
try {
|
||||
await promisify(execFile)('pocketbase', [
|
||||
'superuser',
|
||||
'upsert',
|
||||
superuser.email,
|
||||
superuser.password,
|
||||
'--dir',
|
||||
dataDir,
|
||||
]).catch((error: NodeJS.ErrnoException & { stdout?: string; stderr?: string }) => {
|
||||
if (error.code === 'ENOENT') throw binaryNotFound(error);
|
||||
throw new Error(`pocketbase superuser upsert failed:\n${error.stdout}${error.stderr}`);
|
||||
});
|
||||
|
||||
const url = `http://127.0.0.1:${await freePort()}`;
|
||||
let output = '';
|
||||
let spawnError: Error | undefined;
|
||||
const proc = spawn('pocketbase', [
|
||||
'serve',
|
||||
'--dir',
|
||||
dataDir,
|
||||
'--migrationsDir',
|
||||
'pocketbase/pb_migrations',
|
||||
'--hooksDir',
|
||||
'pocketbase/pb_hooks',
|
||||
'--http',
|
||||
url.slice('http://'.length),
|
||||
// Иначе изменения коллекций, сделанные тестами, попадут в pocketbase/pb_migrations.
|
||||
'--automigrate=false',
|
||||
]);
|
||||
child = proc;
|
||||
proc.stdout.on('data', (chunk: Buffer) => (output += chunk));
|
||||
proc.stderr.on('data', (chunk: Buffer) => (output += chunk));
|
||||
proc.on('error', (error: NodeJS.ErrnoException) => {
|
||||
spawnError = error.code === 'ENOENT' ? binaryNotFound(error) : error;
|
||||
});
|
||||
|
||||
for (const deadline = Date.now() + START_TIMEOUT_MS; ; await delay(100)) {
|
||||
if (spawnError) throw spawnError;
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
throw new Error(`PocketBase exited before becoming ready:\n${output}`);
|
||||
}
|
||||
if (await isHealthy(url)) return { url, dataDir, superuser, stop };
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`PocketBase did not answer /api/health within ${START_TIMEOUT_MS} ms:\n${output}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function binaryNotFound(cause: Error): Error {
|
||||
return new Error('pocketbase binary not found in PATH: install PocketBase and add it to PATH', {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
// shortcut: порт может быть занят между close() и запуском pocketbase serve; при нестабильности добавить повтор при ошибке привязки.
|
||||
async function freePort(): Promise<number> {
|
||||
const server = createServer().listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address() as AddressInfo;
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
return port;
|
||||
}
|
||||
|
||||
async function isHealthy(url: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(1_000) })).ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user