diff --git a/docs/testing.md b/docs/testing.md index 6585211..9cb98bd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,13 +4,13 @@ ## Уровни тестов -| Уровень | Что проверяет | Где лежит | Команда | -| --- | --- | --- | --- | -| **API** (e2e бэкенда) | схема, миграции, правила доступа, изоляция пользователей, хуки PocketBase | `tests/api/**/*.api.spec.ts` | `pnpm test:api` | -| **Unit** | доменные функции, компоненты и сервисы с фейками вместо PocketBase | рядом с кодом, `src/**/*.spec.ts` | `pnpm test` | -| **Integration** | PocketBase-реализации репозиториев и сервисов против настоящего PocketBase | рядом с кодом, `src/**/*.integration.spec.ts` | `pnpm test:integration` | -| **E2E** | пользовательские сценарии в браузере, SSR, доступность (axe) | `tests/e2e/**/*.e2e.ts` | `pnpm e2e` | -| **E2E prod** | сценарии на production-сборке, включая изоляцию данных при SSR | `tests/e2e/` (проект `ssr-prod`) | `pnpm e2e:prod` | +| Уровень | Что проверяет | Где лежит | Команда | +| --------------------- | -------------------------------------------------------------------------- | --------------------------------------------- | ----------------------- | +| **API** (e2e бэкенда) | схема, миграции, правила доступа, изоляция пользователей, хуки PocketBase | `tests/api/**/*.api.spec.ts` | `pnpm test:api` | +| **Unit** | доменные функции, компоненты и сервисы с фейками вместо PocketBase | рядом с кодом, `src/**/*.spec.ts` | `pnpm test` | +| **Integration** | PocketBase-реализации репозиториев и сервисов против настоящего PocketBase | рядом с кодом, `src/**/*.integration.spec.ts` | `pnpm test:integration` | +| **E2E** | пользовательские сценарии в браузере, SSR, доступность (axe) | `tests/e2e/**/*.e2e.ts` | `pnpm e2e` | +| **E2E prod** | сценарии на production-сборке, включая изоляцию данных при SSR | `tests/e2e/` (проект `ssr-prod`) | `pnpm e2e:prod` | Команды появляются по мере того, как настраиваются соответствующие стенды. @@ -24,7 +24,9 @@ export function describeTagRepositoryContract( setup: () => Promise<{ repoA: TagRepository; repoB: 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`: -1. Создаётся временная папка данных. -2. Создаётся суперпользователь (`pocketbase superuser upsert`) с данными из `.env` (`PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD`). -3. Запускается `pocketbase serve` на свободном порту с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks`; миграции применяются при старте. +1. Создаётся временная папка данных (в системной temp-папке). +2. Создаётся суперпользователь (`pocketbase superuser upsert`) со сгенерированными email и паролем: инстанс одноразовый, поэтому `.env` тестам не нужен. +3. Запускается `pocketbase serve` на свободном порту `127.0.0.1` с `--dir <временная папка> --migrationsDir pocketbase/pb_migrations --hooksDir pocketbase/pb_hooks --automigrate=false`; миграции применяются при старте. 4. Ожидание готовности по `GET /api/health`. 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. ## Правила написания тестов diff --git a/package.json b/package.json index 18730a2..b651106 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", + "test:api": "vitest run --config vitest.api.config.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs", diff --git a/pocketbase/pb_hooks/.gitkeep b/pocketbase/pb_hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/global-setup.ts b/tests/api/global-setup.ts new file mode 100644 index 0000000..480523a --- /dev/null +++ b/tests/api/global-setup.ts @@ -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; +} diff --git a/tests/api/harness.api.spec.ts b/tests/api/harness.api.spec.ts new file mode 100644 index 0000000..879f136 --- /dev/null +++ b/tests/api/harness.api.spec.ts @@ -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 => 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(); + } + }); +}); diff --git a/tests/support/clients.ts b/tests/support/clients.ts new file mode 100644 index 0000000..69be96c --- /dev/null +++ b/tests/support/clients.ts @@ -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 { + const { email, password } = inject('pbSuperuser'); + const pb = asGuest(); + await pb.collection('_superusers').authWithPassword(email, password); + return pb; +} + +export async function createUser(overrides: Record = {}): Promise { + 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 { + const pb = asGuest(); + await pb.collection('users').authWithPassword(user.email, user.password); + return pb; +} diff --git a/tests/support/fixtures/hold-pocketbase.ts b/tests/support/fixtures/hold-pocketbase.ts new file mode 100644 index 0000000..fb52e65 --- /dev/null +++ b/tests/support/fixtures/hold-pocketbase.ts @@ -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); diff --git a/tests/support/pocketbase-server.ts b/tests/support/pocketbase-server.ts new file mode 100644 index 0000000..51b09a8 --- /dev/null +++ b/tests/support/pocketbase-server.ts @@ -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; +} + +/** + * Запускает одноразовый PocketBase на свободном порту и ждёт, пока он ответит на `/api/health`. + * + * Процесс и каталог данных удаляются при `stop()`, по `SIGINT`/`SIGTERM`, при выходе + * и при ошибке запуска. + * + * @throws Если `pocketbase` нет в PATH, не создался суперпользователь или сервер не ответил + * за {@link START_TIMEOUT_MS}. В сообщение ошибки включён вывод PocketBase. + */ +export async function startPocketBase(): Promise { + const superuser = { email: `superuser-${randomUUID()}@test.local`, password: randomUUID() }; + const dataDir = await mkdtemp(join(tmpdir(), 'pchelpcrm-pb-')); + let child: ChildProcess | undefined; + let stopping: Promise | 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 { + 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 { + try { + return (await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(1_000) })).ok; + } catch { + return false; + } +} diff --git a/vitest.api.config.ts b/vitest.api.config.ts new file mode 100644 index 0000000..3eccfa2 --- /dev/null +++ b/vitest.api.config.ts @@ -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, + }, +});