diff --git a/README.md b/README.md index c08ea37..365dae3 100644 --- a/README.md +++ b/README.md @@ -47,19 +47,20 @@ pnpm start # приложение на http://localhost:4200/ ## Команды -| Команда | Что делает | -| ----------------------- | -------------------------------------------------------------------- | -| `pnpm start` | dev-сервер приложения | -| `pnpm build` | production-сборка в `dist/` | -| `pnpm lint` | ESLint | -| `pnpm test` | unit-тесты | -| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей | -| `pnpm test:integration` | репозитории приложения против тестового PocketBase | -| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) | -| `pnpm e2e:prod` | сценарии на production-сборке | -| `pnpm pb:serve` | PocketBase для разработки | -| `pnpm pb:migrate` | применить миграции | -| `pnpm pb:superuser` | создать суперпользователя или сбросить его пароль | +| Команда | Что делает | +| ----------------------- | ----------------------------------------------------------------------- | +| `pnpm start` | dev-сервер приложения | +| `pnpm build` | production-сборка в `dist/` | +| `pnpm lint` | ESLint | +| `pnpm test` | unit-тесты | +| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей | +| `pnpm test:integration` | репозитории приложения против тестового PocketBase | +| `pnpm test:lint` | тесты конфига ESLint; первый шаг `test`, `test:api`, `test:integration` | +| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) | +| `pnpm e2e:prod` | сценарии на production-сборке | +| `pnpm pb:serve` | PocketBase для разработки | +| `pnpm pb:migrate` | применить миграции | +| `pnpm pb:superuser` | создать суперпользователя или сбросить его пароль | Тесты, которым нужен PocketBase, сами поднимают временный экземпляр и не трогают `pocketbase/pb_data`. Перед первым запуском e2e установите браузер: `pnpm exec playwright install chromium`. diff --git a/docs/README.md b/docs/README.md index acd9557..92c9f6a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,7 +18,7 @@ | --- | --- | | `guide/project-structure.md` | карта репозитория: `src/app`, `pocketbase/`, `tests/`, `scripts/`; где что искать | | `guide/adding-a-feature.md` | пошаговый пример добавления новой сущности от миграции до экрана (на примере меток) | -| `guide/shared.md` | общая инфраструктура: клиент PocketBase, доменные ошибки, общие UI-компоненты | +| [guide/shared.md](guide/shared.md) | общая инфраструктура: клиент PocketBase, доменные ошибки, общие UI-компоненты | | `guide/auth.md` | вход, регистрация, сессия, cookie, guard'ы, работа с текущим пользователем | | `guide/tags.md` | метки: репозиторий, диалог управления, компонент выбора | | `guide/clients.md` | клиенты: телефоны, поиск, форма, точки расширения карточки | diff --git a/docs/architecture.md b/docs/architecture.md index 3eac4e8..a070fd3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,8 @@ src/app/orders/ | Папка | Назначение | | -------------------- | ---------------------------------------------------------------------------------------- | -| `shared/pocketbase/` | URL PocketBase, фабрика клиента, синхронизация cookie, маппинг ошибок | +| `shared/pocketbase/` | URL PocketBase, фабрика клиента, синхронизация cookie | +| `shared/errors/` | доменные ошибки и `toDomainError` | | `users/` | сущность User (профиль владельца аккаунта), без логики входа | | `auth/` | сессия: вход, регистрация, выход, текущий пользователь, guard'ы, формы входа/регистрации | | `layout/` | каркас приложения: шапка, навигация, меню пользователя | @@ -117,14 +118,18 @@ providers: [ ## 4. Ошибки -`shared/pocketbase/` переводит `ClientResponseError` в доменные ошибки, которые понимает UI: +Доменные ошибки и `toDomainError(error: unknown): Error` живут в `shared/errors/`. `toDomainError` переводит `ClientResponseError` в доменные ошибки, которые понимает UI: -| Доменная ошибка | Когда | -| ------------------- | -------------------------------------------------------------------------------- | -| `NotFoundError` | 404 (в том числе «чужая запись»: PocketBase не различает «нет» и «нет доступа») | -| `ValidationError` | 400 с ошибками по полям: `fieldErrors: Record` (коды PocketBase) | -| `UnauthorizedError` | 401/403: сессия недействительна | -| `NetworkError` | нет связи / PocketBase недоступен | +| Доменная ошибка | Когда | +| ------------------------- | ------------------------------------------------------------------------------------- | +| `NotFoundError` | 404 (в том числе «чужая запись»: PocketBase не различает «нет» и «нет доступа») | +| `ValidationError` | 400 с ошибками по полям: `fieldErrors: Record` (коды PocketBase) | +| `UnauthorizedError` | 401/403: сессия недействительна | +| `NetworkError` | нет связи / PocketBase недоступен (`status === 0`) или запрос прерван | +| `InvalidCredentialsError` | неверный email или пароль; создаёт сервис аутентификации, `toDomainError` её не знает | +| `UnknownError` | всё остальное, в том числе 400 без ошибок по полям; исходная ошибка — в `cause` | + +Все доменные ошибки наследуют `DomainError`: ошибка, уже являющаяся `DomainError`, возвращается из `toDomainError` как есть. Исходная ошибка PocketBase сохраняется в `cause` у каждой ошибки, которую создаёт `toDomainError`. UI показывает ошибки полей рядом с полями, а общие ошибки — в области уведомлений с `role="alert"`. @@ -153,7 +158,7 @@ UI показывает ошибки полей рядом с полями, а На сервере каждый HTTP-запрос получает **свой экземпляр Angular-приложения и свой root-инжектор**. Всё, что живёт в DI, изолировано. Опасно только то, что живёт **вне DI** — на уровне модуля, общее для всех запросов. -1. **Нельзя** создавать PocketBase на уровне модуля (`export const pb = new PocketBase(...)`). Экземпляр создаёт только фабрика в `shared/pocketbase/` внутри DI. ESLint-правило запрещает `new PocketBase(` во всех остальных файлах. +1. **Нельзя** создавать PocketBase на уровне модуля (`export const pb = new PocketBase(...)`). Экземпляр создаёт только фабрика в `shared/pocketbase/` внутри DI. ESLint запрещает `new PocketBase(` и импорт класса `PocketBase` (в том числе под другим именем и через `pocketbase/*`) во всех остальных файлах; `import type` разрешён. 2. **Нельзя** хранить пользовательские данные в переменных модуля, статических полях классов, кэшах вне DI. 3. Сервер рендерит страницы с заголовками `Cache-Control: private, no-store` и `Vary: Cookie`: ни браузер, ни прокси не должны отдать одну страницу двум людям. 4. На сервере используется глобальный `fetch` без общего состояния; `authStore` — свой у каждого экземпляра PocketBase (не `LocalAuthStore`, а in-memory `BaseAuthStore`). @@ -168,7 +173,7 @@ PocketBase SDK использует `fetch`, а не `HttpClient`, поэтом - **Сервер:** `PB_URL` — переменная окружения Node-сервера, внутренний адрес PocketBase для серверного рендера (по умолчанию `http://127.0.0.1:8090`). В браузер не передаётся. - **Браузер:** `/pb`. Префикс `/pb` — константа `PB_PUBLIC_PATH` в `shared/pocketbase/`; если путь в nginx поменяется, меняется только она. -- Всё это задаётся в одном месте: `shared/pocketbase/` (InjectionToken `PB_URL`, разные провайдеры для сервера и браузера). +- Всё это задаётся в одном месте: `shared/pocketbase/`. Клиент `POCKETBASE` создаётся одной и той же фабрикой и в браузере, и на сервере (`providePocketBase()` в `app.config.ts`); различается только токен `PB_URL`: `providePocketBase()` даёт браузерное значение по умолчанию, а `providePbUrlForServer()` в `app.config.server.ts` подменяет его серверным. - **Разработка:** `ng serve` проксирует `/pb/*` → `PB_URL` со срезанием префикса (`proxy.conf.mjs`). E2E-тесты production-сборки используют маленький тестовый фронт-прокси, повторяющий правила nginx. ### Ожидаемая конфигурация nginx (пример, деплой — вне MVP) diff --git a/docs/guide/shared.md b/docs/guide/shared.md new file mode 100644 index 0000000..8ca1767 --- /dev/null +++ b/docs/guide/shared.md @@ -0,0 +1,58 @@ +# Общая инфраструктура (`shared/`) + +Клиент PocketBase, который можно получить только через DI, и доменные ошибки, в которые переводятся ошибки SDK. + +## Где лежит + +``` +src/app/shared/ +├── pocketbase/ +│ ├── pb-url.ts # PB_PUBLIC_PATH, токен PB_URL, серверный провайдер +│ └── pocketbase-client.ts # токен POCKETBASE, фабрика, providePocketBase() +└── errors/ + ├── domain-errors.ts # DomainError (база), NotFoundError, ValidationError, ... + └── to-domain-error.ts # toDomainError(error: unknown): Error +``` + +## Публичный API + +- `PB_URL` — `InjectionToken`: базовый адрес PocketBase. В браузере `/pb`, на сервере `process.env['PB_URL']` или `http://127.0.0.1:8090`. +- `PB_PUBLIC_PATH` — `'/pb'`, путь, по которому браузер обращается к PocketBase. +- `providePbUrlForServer()` — серверный провайдер `PB_URL`; регистрируется в `app.config.server.ts` и перекрывает браузерный. +- `POCKETBASE` — `InjectionToken`: свой экземпляр на каждый инжектор приложения (на сервере — на каждый запрос). Фабрика одна и та же в браузере и на сервере. +- `providePocketBase()` — регистрирует `POCKETBASE` и `PB_URL` по умолчанию (браузерный: `/pb`); вызывается в `app.config.ts`, поэтому действует и в браузере, и на сервере. Возвращает `EnvironmentProviders`: в `providers` компонента его подключить нельзя, иначе у поддерева был бы свой клиент с пустым `authStore`. +- `toDomainError(error)` и классы ошибок из `domain-errors.ts` — таблица соответствий в [architecture.md, раздел 4](../architecture.md). + +## Как пользоваться + +```ts +// data/ слоя фичи +private readonly pb = inject(POCKETBASE); + +async list(): Promise { + try { + return (await this.pb.collection('tags').getFullList()).map(toTag); + } catch (error) { + throw toDomainError(error); + } +} +``` + +## Как расширить или заменить + +- В тестах вместо клиента подставляется `{ provide: POCKETBASE, useValue: ... }` или тестовый `PB_URL`. +- `InvalidCredentialsError` создаёт сервис аутентификации; `toDomainError` её не знает. + +## Тесты + +- `to-domain-error.spec.ts`, `pocketbase-client.spec.ts` — unit. +- `pocketbase-client.integration.spec.ts` — клиент из DI против тестового PocketBase, изоляция двух пользователей и гостя. +- `tests/lint/eslint-rules.spec.ts` — границы ESLint, см. [testing.md](../testing.md). + +## Подводные камни + +- `new PocketBase(` и импорт класса `PocketBase` (в том числе из `pocketbase/*`) разрешены только в фабрике `pocketbase-client.ts` (и в `tests/`; `import type` можно везде кроме `domain/` и `ui/`); импорт `pocketbase` и `pocketbase/*` запрещён в `domain/` и `ui/`. См. [architecture.md, раздел 6](../architecture.md). +- Новую доменную ошибку наследуйте от `DomainError`, а не от `Error`: только так `toDomainError` узнаёт её и возвращает как есть. Иначе она будет завёрнута в `UnknownError`, и UI её не распознает. +- `authStore` — `BaseAuthStore` в памяти, не `LocalAuthStore`: на сервере иначе данные пользователей были бы общими. +- Автоотмена запросов SDK выключена: параллельные запросы одного типа не отменяют друг друга. +- Серверный `PB_URL` — внутренний адрес; он не попадает в браузер, браузер ходит через `/pb`. См. [architecture.md, раздел 7](../architecture.md). diff --git a/docs/testing.md b/docs/testing.md index 3c2ad52..ddfa2fa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,7 +35,15 @@ Почему цель builder'а, а не отдельный `vitest.integration.config.ts` с плагином: спеки собираются тем же компилятором Angular и с той же инициализацией TestBed, что и в `pnpm test`, без своего плагина компиляции и его настройки. -`tests/support/clients.ts` компилируется в двух программах — `tsconfig.tests.json` и `tsconfig.spec.json`. Поэтому в нём нет `node:*` (во второй программе нет типов Node), а `tests/support/provided-context.d.ts` с типами `inject('pbUrl')` перечислен в `include` обоих tsconfig: `references` в `tsconfig.json` программы не объединяют. +`tests/support/clients.ts` компилируется в двух программах — `tsconfig.tests.json` и `tsconfig.spec.json`. Типы Node подключены в обеих (спеки выполняются Vitest в Node, а код в `src/` вроде серверного провайдера `PB_URL` читает `process.env`), а `tests/support/provided-context.d.ts` с типами `inject('pbUrl')` перечислен в `include` обоих tsconfig: `references` в `tsconfig.json` программы не объединяют. + +## Тесты конфига ESLint + +`pnpm test:lint` = `tsc -p tsconfig.tests.json && vitest run --config vitest.lint.config.mts`: чистый Vitest в Node, файлы `tests/lint/**/*.spec.ts`. + +- Проверяют, что ESLint держит границы, от которых зависит изоляция данных при SSR: `new PocketBase(` и импорт класса `PocketBase` (в том числе под другим именем и через `pocketbase/*`) только в фабрике `shared/pocketbase/pocketbase-client.ts` (и в тестовых хелперах `tests/`), импорт `pocketbase` и `pocketbase/*` запрещён в `domain/` и `ui/`. +- Способ: `ESLint#lintText(code, { filePath })` на коротких фрагментах кода, проверяется `ruleId` сообщений. Конфиг ESLint ломается молча (опечатка в glob, слишком широкое исключение), и `pnpm lint` при этом остаётся зелёным; тест это ловит. +- Запускаются первым шагом в `pnpm test`, `pnpm test:api` и `pnpm test:integration`, чтобы поломка правила обнаружилась при любом прогоне. ESLint — Node-библиотека, в бандл builder'а Angular её не подключить, поэтому это отдельный набор. ## Контрактные тесты репозиториев diff --git a/eslint.config.mjs b/eslint.config.mjs index 5dd968b..6d2699f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,6 +26,51 @@ export default defineConfig([ ], }, }, + { + files: ['**/*.ts'], + ignores: ['src/app/shared/pocketbase/pocketbase-client.ts', 'tests/**'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "NewExpression[callee.name='PocketBase']", + message: + 'Клиент PocketBase создаёт только фабрика в shared/pocketbase/pocketbase-client.ts; получайте его через DI (POCKETBASE).', + }, + ], + // Импорт под другим именем или через subpath (`import PB from 'pocketbase/cjs'`) обходит селектор выше; `import type` остаётся можно. + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['pocketbase', 'pocketbase/*'], + importNames: ['default'], + allowTypeImports: true, + message: + 'Класс PocketBase импортирует только фабрика в shared/pocketbase/pocketbase-client.ts; получайте клиент через DI (POCKETBASE).', + }, + ], + }, + ], + }, + }, + { + files: ['**/domain/**/*.ts', '**/ui/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['pocketbase', 'pocketbase/*'], + message: 'pocketbase можно импортировать только в data/ и shared/.', + }, + ], + }, + ], + }, + }, { files: ['**/*.html'], extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility], diff --git a/package.json b/package.json index f25a178..619dd35 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test", - "test:api": "tsc -p tsconfig.tests.json && vitest run --config vitest.api.config.mts", - "test:integration": "ng run pchelpcrm:test-integration", + "test": "pnpm test:lint && ng test", + "test:lint": "tsc -p tsconfig.tests.json && vitest run --config vitest.lint.config.mts", + "test:api": "pnpm test:lint && vitest run --config vitest.api.config.mts", + "test:integration": "pnpm test:lint && ng run pchelpcrm:test-integration", "lint": "eslint .", "lint:fix": "eslint . --fix", "serve:ssr:pchelpcrm": "node dist/pchelpcrm/server/server.mjs", diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index 98fb421..15de79b 100644 --- a/src/app/app.config.server.ts +++ b/src/app/app.config.server.ts @@ -2,9 +2,10 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; import { provideServerRendering, withRoutes } from '@angular/ssr'; import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; +import { providePbUrlForServer } from './shared/pocketbase/pb-url'; const serverConfig: ApplicationConfig = { - providers: [provideServerRendering(withRoutes(serverRoutes))], + providers: [provideServerRendering(withRoutes(serverRoutes)), providePbUrlForServer()], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 2c069de..244b624 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -2,11 +2,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { provideClientHydration } from '@angular/platform-browser'; +import { providePocketBase } from './shared/pocketbase/pocketbase-client'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), provideClientHydration(), + providePocketBase(), ], }; diff --git a/src/app/shared/errors/domain-errors.ts b/src/app/shared/errors/domain-errors.ts new file mode 100644 index 0000000..b5f2bf0 --- /dev/null +++ b/src/app/shared/errors/domain-errors.ts @@ -0,0 +1,25 @@ +// Доменные ошибки, понятные UI. Из ошибок PocketBase SDK их делает toDomainError(). + +/** Общий предок доменных ошибок: по нему toDomainError() узнаёт уже переведённую ошибку. */ +export class DomainError extends Error {} + +export class NotFoundError extends DomainError {} + +export class ValidationError extends DomainError { + /** Код ошибки PocketBase по каждому полю, например `{ email: 'validation_invalid_email' }`. */ + constructor( + readonly fieldErrors: Record, + options?: ErrorOptions, + ) { + super(undefined, options); + } +} + +export class UnauthorizedError extends DomainError {} + +/** Неверный email или пароль; создаёт сервис аутентификации, toDomainError() её не знает. */ +export class InvalidCredentialsError extends DomainError {} + +export class NetworkError extends DomainError {} + +export class UnknownError extends DomainError {} diff --git a/src/app/shared/errors/to-domain-error.spec.ts b/src/app/shared/errors/to-domain-error.spec.ts new file mode 100644 index 0000000..114451c --- /dev/null +++ b/src/app/shared/errors/to-domain-error.spec.ts @@ -0,0 +1,106 @@ +import { ClientResponseError } from 'pocketbase'; +import { + DomainError, + NetworkError, + NotFoundError, + UnauthorizedError, + UnknownError, + ValidationError, +} from './domain-errors'; +import { toDomainError } from './to-domain-error'; + +function pbError(status: number, data: Record = {}): ClientResponseError { + return new ClientResponseError({ status, response: { status, message: 'Ошибка', data } }); +} + +describe('toDomainError', () => { + it('404 → NotFoundError', () => { + expect(toDomainError(pbError(404))).toBeInstanceOf(NotFoundError); + }); + + it('400 с ошибками по полям → ValidationError с кодами по полям', () => { + const error = toDomainError( + pbError(400, { + email: { code: 'validation_invalid_email', message: 'Must be a valid email.' }, + password: { code: 'validation_length_out_of_range', message: 'Too short.' }, + }), + ); + + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).fieldErrors).toEqual({ + email: 'validation_invalid_email', + password: 'validation_length_out_of_range', + }); + }); + + it('400 без ошибок по полям → UnknownError с исходной ошибкой в cause', () => { + const original = pbError(400); + + const error = toDomainError(original); + + expect(error).toBeInstanceOf(UnknownError); + expect(error.cause).toBe(original); + }); + + it.each([401, 403])('%i → UnauthorizedError', (status) => { + expect(toDomainError(pbError(status))).toBeInstanceOf(UnauthorizedError); + }); + + it('status 0 (нет связи) → NetworkError', () => { + expect(toDomainError(new ClientResponseError({ status: 0 }))).toBeInstanceOf(NetworkError); + }); + + it('прерванный запрос → NetworkError', () => { + expect(toDomainError(new ClientResponseError({ isAbort: true }))).toBeInstanceOf( + NetworkError, + ); + }); + + it.each([429, 500])('%i → UnknownError с исходной ошибкой в cause', (status) => { + const original = pbError(status); + + const error = toDomainError(original); + + expect(error).toBeInstanceOf(UnknownError); + expect(error.cause).toBe(original); + }); + + it.each([new Error('boom'), 'строка', undefined])( + 'произвольное значение (%s) → UnknownError с ним в cause', + (original) => { + const error = toDomainError(original); + + expect(error).toBeInstanceOf(UnknownError); + expect(error.cause).toBe(original); + }, + ); + + it('уже доменную ошибку возвращает как есть', () => { + const original = new NotFoundError(); + + expect(toDomainError(original)).toBe(original); + }); + + it('любую доменную ошибку возвращает как есть, в том числе новую', () => { + class ConflictError extends DomainError {} + const original = new ConflictError(); + + expect(toDomainError(original)).toBe(original); + }); + + it('400 с полями без строкового code → UnknownError', () => { + const error = toDomainError(pbError(400, { email: { message: 'Нет кода' }, name: null })); + + expect(error).toBeInstanceOf(UnknownError); + }); + + it('сохраняет исходную ошибку в cause у NotFound, Unauthorized и Network', () => { + const notFound = pbError(404); + const unauthorized = pbError(401); + const network = new ClientResponseError({ status: 0 }); + + expect(toDomainError(notFound).cause).toBe(notFound); + expect(toDomainError(unauthorized).cause).toBe(unauthorized); + expect(toDomainError(network).cause).toBe(network); + }); +}); diff --git a/src/app/shared/errors/to-domain-error.ts b/src/app/shared/errors/to-domain-error.ts new file mode 100644 index 0000000..f36a951 --- /dev/null +++ b/src/app/shared/errors/to-domain-error.ts @@ -0,0 +1,49 @@ +import { ClientResponseError } from 'pocketbase'; +import { + DomainError, + NetworkError, + NotFoundError, + UnauthorizedError, + UnknownError, + ValidationError, +} from './domain-errors'; + +/** Переводит ошибку PocketBase SDK (`ClientResponseError`) в доменную ошибку из `domain-errors.ts`. */ +export function toDomainError(error: unknown): Error { + if (error instanceof DomainError) { + return error; + } + if (!(error instanceof ClientResponseError)) { + return new UnknownError(undefined, { cause: error }); + } + if (error.isAbort || error.status === 0) { + return new NetworkError(undefined, { cause: error }); + } + if (error.status === 404) { + return new NotFoundError(undefined, { cause: error }); + } + if (error.status === 401 || error.status === 403) { + return new UnauthorizedError(undefined, { cause: error }); + } + if (error.status === 400) { + const fieldErrors = toFieldErrors(error.response['data']); + if (Object.keys(fieldErrors).length > 0) { + return new ValidationError(fieldErrors, { cause: error }); + } + } + return new UnknownError(undefined, { cause: error }); +} + +function toFieldErrors(data: unknown): Record { + const fieldErrors: Record = {}; + if (typeof data !== 'object' || data === null) { + return fieldErrors; + } + for (const [field, value] of Object.entries(data)) { + const code = (value as { code?: unknown } | null)?.code; + if (typeof code === 'string') { + fieldErrors[field] = code; + } + } + return fieldErrors; +} diff --git a/src/app/shared/pocketbase/pb-url.ts b/src/app/shared/pocketbase/pb-url.ts new file mode 100644 index 0000000..09f7f36 --- /dev/null +++ b/src/app/shared/pocketbase/pb-url.ts @@ -0,0 +1,15 @@ +import { InjectionToken, Provider } from '@angular/core'; + +/** Путь, по которому браузер обращается к PocketBase на своём origin (nginx / прокси dev-сервера). */ +export const PB_PUBLIC_PATH = '/pb'; + +/** Базовый адрес PocketBase: в браузере `/pb`, на сервере — `process.env.PB_URL`. */ +export const PB_URL = new InjectionToken('PB_URL'); + +/** Серверный провайдер `PB_URL` (регистрируется в `app.config.server.ts`). */ +export function providePbUrlForServer(): Provider { + return { + provide: PB_URL, + useFactory: () => process.env['PB_URL'] || 'http://127.0.0.1:8090', + }; +} diff --git a/src/app/shared/pocketbase/pocketbase-client.integration.spec.ts b/src/app/shared/pocketbase/pocketbase-client.integration.spec.ts new file mode 100644 index 0000000..0c32be6 --- /dev/null +++ b/src/app/shared/pocketbase/pocketbase-client.integration.spec.ts @@ -0,0 +1,64 @@ +import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type PocketBase from 'pocketbase'; +import { inject } from 'vitest'; +import { createUser, type TestUser } from '../../../../tests/support/clients'; +import { PB_URL } from './pb-url'; +import { POCKETBASE, providePocketBase } from './pocketbase-client'; + +// Клиент из отдельного инжектора приложения, как у отдельного SSR-запроса. +function pocketBaseFromDi(): PocketBase { + return createEnvironmentInjector( + [providePocketBase(), { provide: PB_URL, useValue: inject('pbUrl') }], + TestBed.inject(EnvironmentInjector), + ).get(POCKETBASE); +} + +async function signedIn(user: TestUser): Promise { + const pb = pocketBaseFromDi(); + await pb.collection('users').authWithPassword(user.email, user.password); + return pb; +} + +describe('клиент PocketBase из DI против тестового PocketBase', () => { + it('получает health со статусом 200', async () => { + const health = await pocketBaseFromDi().health.check(); + + expect(health.code).toBe(200); + }); + + it('не отменяет параллельные запросы одного типа', async () => { + const pb = pocketBaseFromDi(); + + const results = await Promise.all([pb.health.check(), pb.health.check()]); + + expect(results.map((r) => r.code)).toEqual([200, 200]); + }); + + it('клиенты двух пользователей и гостя не видят чужих записей', async () => { + const [userA, userB] = await Promise.all([createUser(), createUser()]); + const [pbA, pbB] = await Promise.all([signedIn(userA), signedIn(userB)]); + const pbGuest = pocketBaseFromDi(); + + expect(pbA.authStore.record?.id).toBe(userA.id); + expect(pbB.authStore.record?.id).toBe(userB.id); + expect(pbA.authStore.token).not.toBe(pbB.authStore.token); + expect(pbGuest.authStore.isValid).toBe(false); + + await expect(pbA.collection('users').getOne(userA.id)).resolves.toMatchObject({ + id: userA.id, + }); + await expect(pbB.collection('users').getOne(userB.id)).resolves.toMatchObject({ + id: userB.id, + }); + await expect(pbA.collection('users').getOne(userB.id)).rejects.toMatchObject({ + status: 404, + }); + await expect(pbB.collection('users').getOne(userA.id)).rejects.toMatchObject({ + status: 404, + }); + await expect(pbGuest.collection('users').getOne(userA.id)).rejects.toMatchObject({ + status: 404, + }); + }); +}); diff --git a/src/app/shared/pocketbase/pocketbase-client.spec.ts b/src/app/shared/pocketbase/pocketbase-client.spec.ts new file mode 100644 index 0000000..7a88ae7 --- /dev/null +++ b/src/app/shared/pocketbase/pocketbase-client.spec.ts @@ -0,0 +1,87 @@ +import { DOCUMENT, EnvironmentInjector, createEnvironmentInjector } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { LocalAuthStore } from 'pocketbase'; +import { PB_PUBLIC_PATH, PB_URL, providePbUrlForServer } from './pb-url'; +import { POCKETBASE, providePocketBase } from './pocketbase-client'; + +function createAppInjector(): EnvironmentInjector { + return createEnvironmentInjector( + [providePocketBase(), { provide: PB_URL, useValue: 'http://pb.test:1234' }], + TestBed.inject(EnvironmentInjector), + ); +} + +describe('клиент PocketBase в DI', () => { + it('в одном инжекторе отдаёт один и тот же экземпляр', () => { + TestBed.configureTestingModule({ providers: [providePocketBase()] }); + + expect(TestBed.inject(POCKETBASE)).toBe(TestBed.inject(POCKETBASE)); + }); + + it('в двух инжекторах отдаёт разные экземпляры с независимым authStore', () => { + const a = createAppInjector().get(POCKETBASE); + const b = createAppInjector().get(POCKETBASE); + + a.authStore.save('token-a', null); + + expect(a).not.toBe(b); + expect(b.authStore.token).toBe(''); + expect(b.authStore.isValid).toBe(false); + }); + + it('берёт baseURL из PB_URL', () => { + TestBed.configureTestingModule({ + providers: [providePocketBase(), { provide: PB_URL, useValue: 'http://pb.test:1234' }], + }); + + expect(TestBed.inject(POCKETBASE).baseURL).toBe('http://pb.test:1234'); + }); + + it('хранит авторизацию в памяти, а не в localStorage', () => { + TestBed.configureTestingModule({ providers: [providePocketBase()] }); + const pb = TestBed.inject(POCKETBASE); + const keysBefore = Object.keys(localStorage); + + pb.authStore.save('token', null); + + expect(pb.authStore).not.toBeInstanceOf(LocalAuthStore); + expect(Object.keys(localStorage)).toEqual(keysBefore); + }); +}); + +describe('PB_URL', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('PB_PUBLIC_PATH равен /pb', () => { + expect(PB_PUBLIC_PATH).toBe('/pb'); + }); + + it('в браузере — /pb', () => { + TestBed.configureTestingModule({ providers: [providePocketBase()] }); + + expect(TestBed.inject(PB_URL)).toBe(`${TestBed.inject(DOCUMENT).location.origin}/pb`); + }); + + it('на сервере — значение переменной окружения PB_URL', () => { + vi.stubEnv('PB_URL', 'http://pb.internal:9000'); + TestBed.configureTestingModule({ providers: [providePbUrlForServer()] }); + + expect(TestBed.inject(PB_URL)).toBe('http://pb.internal:9000'); + }); + + it('на сервере без PB_URL — http://127.0.0.1:8090', () => { + vi.stubEnv('PB_URL', undefined); + TestBed.configureTestingModule({ providers: [providePbUrlForServer()] }); + + expect(TestBed.inject(PB_URL)).toBe('http://127.0.0.1:8090'); + }); + + it('на сервере с пустым PB_URL — http://127.0.0.1:8090', () => { + vi.stubEnv('PB_URL', ''); + TestBed.configureTestingModule({ providers: [providePbUrlForServer()] }); + + expect(TestBed.inject(PB_URL)).toBe('http://127.0.0.1:8090'); + }); +}); diff --git a/src/app/shared/pocketbase/pocketbase-client.ts b/src/app/shared/pocketbase/pocketbase-client.ts new file mode 100644 index 0000000..f7863c5 --- /dev/null +++ b/src/app/shared/pocketbase/pocketbase-client.ts @@ -0,0 +1,27 @@ +import { + DOCUMENT, + EnvironmentProviders, + InjectionToken, + inject, + makeEnvironmentProviders, +} from '@angular/core'; +import PocketBase, { BaseAuthStore } from 'pocketbase'; +import { PB_PUBLIC_PATH, PB_URL } from './pb-url'; + +/** Клиент PocketBase: свой экземпляр на каждый инжектор приложения (на сервере — на каждый запрос). */ +export const POCKETBASE = new InjectionToken('POCKETBASE'); + +/** Регистрирует `PB_URL` (браузерный) и `POCKETBASE`; вызывается в `app.config.ts`. Только для инжектора приложения, не для `providers` компонента. */ +export function providePocketBase(): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: PB_URL, + useFactory: () => inject(DOCUMENT).location.origin + PB_PUBLIC_PATH, + }, + { + provide: POCKETBASE, + useFactory: () => + new PocketBase(inject(PB_URL), new BaseAuthStore()).autoCancellation(false), + }, + ]); +} diff --git a/src/testing/harness.integration.spec.ts b/src/testing/harness.integration.spec.ts deleted file mode 100644 index 721ef40..0000000 --- a/src/testing/harness.integration.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Временный дымовой тест стенда интеграционных тестов; удалить, когда появятся настоящие *.integration.spec.ts. -import { InjectionToken, Injector } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import type PocketBase from 'pocketbase'; -import { inject } from 'vitest'; -import { asGuest, asSuperuser, asUser, createUser } from '../../tests/support/clients'; - -const PB = new InjectionToken('PB'); - -describe('стенд интеграционных тестов', () => { - it('URL тестового PocketBase доступен и GET /api/health отвечает 200', async () => { - const response = await fetch(`${inject('pbUrl')}/api/health`); - - expect(response.status).toBe(200); - }); - - it('TestBed.inject() работает', () => { - const GREETING = new InjectionToken('GREETING'); - TestBed.configureTestingModule({ providers: [{ provide: GREETING, useValue: 'привет' }] }); - - expect(TestBed.inject(Injector).get(GREETING)).toBe('привет'); - }); - - it('createUser() создаёт пользователя на тестовом PocketBase', async () => { - const user = await createUser(); - - const record = await (await asSuperuser()).collection('users').getOne(user.id); - - expect(record['email']).toBe(user.email); - }); - - it('asUser(user) авторизован именно этим пользователем', async () => { - const user = await createUser(); - - const pb = await asUser(user); - - expect(pb.authStore.record?.id).toBe(user.id); - }); - - it('клиент PocketBase, полученный через DI, обращается к тестовому PocketBase', async () => { - TestBed.configureTestingModule({ providers: [{ provide: PB, useFactory: asGuest }] }); - - const health = await TestBed.inject(PB).health.check(); - - expect(health.code).toBe(200); - }); -}); diff --git a/tests/lint/eslint-rules.spec.ts b/tests/lint/eslint-rules.spec.ts new file mode 100644 index 0000000..ef9daba --- /dev/null +++ b/tests/lint/eslint-rules.spec.ts @@ -0,0 +1,87 @@ +// Проверяет, что конфиг ESLint действительно держит границы, от которых зависит изоляция данных при SSR: +// конфиг ломается молча (опечатка в glob, слишком широкое исключение), а `pnpm lint` при этом остаётся зелёным. +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ESLint } from 'eslint'; +import { describe, expect, it } from 'vitest'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const eslint = new ESLint({ cwd: root }); + +async function ruleIds(code: string, file: string): Promise { + const [result] = await eslint.lintText(code, { filePath: join(root, file) }); + return result.messages.map((m) => m.ruleId ?? ''); +} + +const newPocketBase = `import PocketBase from 'pocketbase';\nexport const pb = new PocketBase('http://x');\n`; +const typeImport = `import type { RecordModel } from 'pocketbase';\nexport type R = RecordModel;\n`; +const valueImport = `import PocketBase from 'pocketbase';\nexport const P = PocketBase;\n`; + +const aliasedImport = `import PB from 'pocketbase';\nexport const pb = new PB('http://x');\n`; +const subpathImport = `import PocketBase from 'pocketbase/cjs';\nexport const P = PocketBase;\n`; +const defaultTypeImport = `import type PocketBase from 'pocketbase';\nexport type P = PocketBase;\n`; +const aliasedSubpathImport = `import PB from 'pocketbase/cjs';\nexport const pb = new PB('http://x');\n`; + +describe('ESLint: new PocketBase(', () => { + it('запрещён и под другим именем импорта (import PB from)', async () => { + expect(await ruleIds(aliasedImport, 'src/app/clients/data/x.ts')).toContain( + '@typescript-eslint/no-restricted-imports', + ); + }); + + it('запрещён и через subpath (import PB from pocketbase/cjs)', async () => { + expect(await ruleIds(aliasedSubpathImport, 'src/app/clients/data/x.ts')).toContain( + '@typescript-eslint/no-restricted-imports', + ); + }); + + it('import type PocketBase разрешён вне фабрики', async () => { + expect(await ruleIds(defaultTypeImport, 'src/app/clients/data/x.ts')).not.toContain( + '@typescript-eslint/no-restricted-imports', + ); + }); + + it('запрещён вне фабрики', async () => { + expect(await ruleIds(newPocketBase, 'src/app/clients/data/x.ts')).toContain( + 'no-restricted-syntax', + ); + }); + + it('разрешён в фабрике shared/pocketbase/pocketbase-client.ts', async () => { + expect( + await ruleIds(newPocketBase, 'src/app/shared/pocketbase/pocketbase-client.ts'), + ).not.toContain('no-restricted-syntax'); + }); + + it('разрешён в тестовых хелперах tests/', async () => { + expect(await ruleIds(newPocketBase, 'tests/support/x.ts')).not.toContain( + 'no-restricted-syntax', + ); + }); +}); + +describe('ESLint: импорт pocketbase', () => { + it('запрещён в domain/', async () => { + expect(await ruleIds(valueImport, 'src/app/clients/domain/x.ts')).toContain( + 'no-restricted-imports', + ); + }); + + it('запрещён в ui/, в том числе import type', async () => { + expect(await ruleIds(typeImport, 'src/app/clients/ui/x/x.ts')).toContain( + 'no-restricted-imports', + ); + }); + + it('запрещён в ui/ и через subpath (pocketbase/cjs)', async () => { + expect(await ruleIds(subpathImport, 'src/app/clients/ui/x/x.ts')).toContain( + 'no-restricted-imports', + ); + }); + + it('разрешён в data/', async () => { + expect(await ruleIds(valueImport, 'src/app/clients/data/x.ts')).not.toContain( + 'no-restricted-imports', + ); + }); +}); diff --git a/tests/support/clients.ts b/tests/support/clients.ts index 00ceba6..4728609 100644 --- a/tests/support/clients.ts +++ b/tests/support/clients.ts @@ -1,5 +1,5 @@ // PocketBase SDK clients for tests; each helper returns its own instance with an in-memory auth store. -// Используется и из src/**/*.integration.spec.ts (tsconfig.spec.json), где нет типов Node: поэтому глобальный crypto вместо node:crypto. +// Используется и из src/**/*.integration.spec.ts (tsconfig.spec.json), то есть и в бандле builder'а Angular: поэтому глобальный crypto вместо node:crypto. import PocketBase, { BaseAuthStore } from 'pocketbase'; import { inject } from 'vitest'; diff --git a/tsconfig.spec.json b/tsconfig.spec.json index a0057f8..d6ee75a 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -3,7 +3,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": ["vitest/globals"] + "types": ["vitest/globals", "node"] }, "include": ["src/**/*.d.ts", "src/**/*.spec.ts", "tests/support/provided-context.d.ts"] } diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 344ffee..e5bd54a 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -8,5 +8,10 @@ "types": ["node"] }, "files": [], - "include": ["tests/**/*.ts", "vitest.api.config.mts", "vitest.integration.config.mts"] + "include": [ + "tests/**/*.ts", + "vitest.api.config.mts", + "vitest.integration.config.mts", + "vitest.lint.config.mts" + ] } diff --git a/vitest.lint.config.mts b/vitest.lint.config.mts new file mode 100644 index 0000000..2a39edc --- /dev/null +++ b/vitest.lint.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +// Тесты конфига ESLint: запускаются первым шагом в pnpm test, test:api и test:integration. +export default defineConfig({ + test: { + include: ['tests/lint/**/*.spec.ts'], + }, +});