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:
kamask
2026-10-26 17:40:36 +03:00
co-authored by Claude Opus 5.5
parent b7b32a0457
commit 5d5b2af318
9 changed files with 413 additions and 12 deletions
+16 -12
View File
@@ -4,13 +4,13 @@
## Уровни тестов ## Уровни тестов
| Уровень | Что проверяет | Где лежит | Команда | | Уровень | Что проверяет | Где лежит | Команда |
| --- | --- | --- | --- | | --------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- |
| **API** (e2e бэкенда) | схема, миграции, правила доступа, изоляция пользователей, хуки PocketBase | `tests/api/**/*.api.spec.ts` | `pnpm test:api` | | **API** (e2e бэкенда) | схема, миграции, правила доступа, изоляция пользователей, хуки PocketBase | `tests/api/**/*.api.spec.ts` | `pnpm test:api` |
| **Unit** | доменные функции, компоненты и сервисы с фейками вместо PocketBase | рядом с кодом, `src/**/*.spec.ts` | `pnpm test` | | **Unit** | доменные функции, компоненты и сервисы с фейками вместо PocketBase | рядом с кодом, `src/**/*.spec.ts` | `pnpm test` |
| **Integration** | PocketBase-реализации репозиториев и сервисов против настоящего PocketBase | рядом с кодом, `src/**/*.integration.spec.ts` | `pnpm test:integration` | | **Integration** | PocketBase-реализации репозиториев и сервисов против настоящего PocketBase | рядом с кодом, `src/**/*.integration.spec.ts` | `pnpm test:integration` |
| **E2E** | пользовательские сценарии в браузере, SSR, доступность (axe) | `tests/e2e/**/*.e2e.ts` | `pnpm e2e` | | **E2E** | пользовательские сценарии в браузере, SSR, доступность (axe) | `tests/e2e/**/*.e2e.ts` | `pnpm e2e` |
| **E2E prod** | сценарии на production-сборке, включая изоляцию данных при SSR | `tests/e2e/` (проект `ssr-prod`) | `pnpm e2e:prod` | | **E2E prod** | сценарии на production-сборке, включая изоляцию данных при SSR | `tests/e2e/` (проект `ssr-prod`) | `pnpm e2e:prod` |
Команды появляются по мере того, как настраиваются соответствующие стенды. Команды появляются по мере того, как настраиваются соответствующие стенды.
@@ -24,7 +24,9 @@ export function describeTagRepositoryContract(
setup: () => Promise<{ repoA: TagRepository; repoB: TagRepository }>, setup: () => Promise<{ repoA: TagRepository; repoB: TagRepository }>,
) { ) {
describe('контракт TagRepository', () => { describe('контракт TagRepository', () => {
it('не показывает пользователю B метки пользователя A', async () => { /* ... */ }); it('не показывает пользователю B метки пользователя A', async () => {
/* ... */
});
}); });
} }
``` ```
@@ -35,13 +37,15 @@ export function describeTagRepositoryContract(
API-, integration- и e2e-тесты работают с **отдельным временным экземпляром** PocketBase, а не с `pocketbase/pb_data`: API-, integration- и e2e-тесты работают с **отдельным временным экземпляром** PocketBase, а не с `pocketbase/pb_data`:
1. Создаётся временная папка данных. 1. Создаётся временная папка данных (в системной temp-папке).
2. Создаётся суперпользователь (`pocketbase superuser upsert`) с данными из `.env` (`PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD`). 2. Создаётся суперпользователь (`pocketbase superuser upsert`) со сгенерированными email и паролем: инстанс одноразовый, поэтому `.env` тестам не нужен.
3. Запускается `pocketbase serve` на свободном порту с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks`; миграции применяются при старте. 3. Запускается `pocketbase serve` на свободном порту `127.0.0.1` с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks --automigrate=false`; миграции применяются при старте.
4. Ожидание готовности по `GET /api/health`. 4. Ожидание готовности по `GET /api/health`.
5. После прогона процесс останавливается, папка удаляется. 5. После прогона процесс останавливается, папка удаляется.
Код запуска один на все уровни: `tests/support/pocketbase-server.ts`. Код запуска один на все уровни: `tests/support/pocketbase-server.ts` (`startPocketBase(): Promise<{ url, dataDir, superuser, stop }>`). В API-тестах URL и суперпользователь доступны через `inject('pbUrl')` и `inject('pbSuperuser')`.
Стенд убирает за собой процесс и папку после успешного прогона, после падения (в том числе неудачного старта), по `SIGINT`/`SIGTERM` (Ctrl+C) и при выходе процесса. Предел: `SIGKILL` процесса-владельца перехватить нельзя — тогда PocketBase может продолжить работу, а папка `pchelpcrm-pb-*` остаться в temp.
## Правила написания тестов ## Правила написания тестов
+1
View File
@@ -7,6 +7,7 @@
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"test:api": "vitest run --config vitest.api.config.ts",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs", "serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs",
View File
+11
View File
@@ -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;
}
+178
View File
@@ -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();
}
});
});
+45
View File
@@ -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);
+145
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/api/**/*.api.spec.ts'],
globalSetup: ['tests/api/global-setup.ts'],
// Headroom for starting a temporary PocketBase inside a test.
testTimeout: 20_000,
hookTimeout: 20_000,
},
});