import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; import { existsSync, readdirSync } 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'); const migrationsDir = path.resolve('pocketbase/pb_migrations'); 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('клиенты двух пользователей и гостя не делят авторизацию', async () => { const a = await createUser(); const b = await createUser(); const pbA = await asUser(a); const pbB = await asUser(b); const guest = asGuest(); expect(pbA.authStore.record?.id).toBe(a.id); expect(pbB.authStore.record?.id).toBe(b.id); expect(guest.authStore.isValid).toBe(false); expect((await pbA.collection('users').getOne(a.id)).id).toBe(a.id); expect((await pbB.collection('users').getOne(b.id)).id).toBe(b.id); }); it('изменения коллекций не порождают файлы в pocketbase/pb_migrations', async () => { const before = readdirSync(migrationsDir); await ( await asSuperuser() ).collections.create({ name: `automigrate_probe_${randomUUID().replaceAll('-', '')}`, type: 'base', }); expect(readdirSync(migrationsDir)).toEqual(before); }); 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 { // Если тест упал до сигнала, даём владельцу убрать PocketBase сам; SIGKILL — только по таймауту. if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit'); child.kill('SIGTERM'); const timer = setTimeout(() => child.kill('SIGKILL'), 5_000); await exited; clearTimeout(timer); } } }, ); 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(); } }); });