mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
feat(shared): add pocketbase di client and domain errors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hvR7iG75jK5byaBBHdDLT
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fc55456286
commit
bec31ff02e
@@ -48,13 +48,14 @@ pnpm start # приложение на http://localhost:4200/
|
|||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
| Команда | Что делает |
|
| Команда | Что делает |
|
||||||
| ----------------------- | -------------------------------------------------------------------- |
|
| ----------------------- | ----------------------------------------------------------------------- |
|
||||||
| `pnpm start` | dev-сервер приложения |
|
| `pnpm start` | dev-сервер приложения |
|
||||||
| `pnpm build` | production-сборка в `dist/` |
|
| `pnpm build` | production-сборка в `dist/` |
|
||||||
| `pnpm lint` | ESLint |
|
| `pnpm lint` | ESLint |
|
||||||
| `pnpm test` | unit-тесты |
|
| `pnpm test` | unit-тесты |
|
||||||
| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей |
|
| `pnpm test:api` | API-тесты PocketBase: схема, правила доступа, изоляция пользователей |
|
||||||
| `pnpm test:integration` | репозитории приложения против тестового PocketBase |
|
| `pnpm test:integration` | репозитории приложения против тестового PocketBase |
|
||||||
|
| `pnpm test:lint` | тесты конфига ESLint; первый шаг `test`, `test:api`, `test:integration` |
|
||||||
| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) |
|
| `pnpm e2e` | сценарии в браузере и проверка доступности (axe) |
|
||||||
| `pnpm e2e:prod` | сценарии на production-сборке |
|
| `pnpm e2e:prod` | сценарии на production-сборке |
|
||||||
| `pnpm pb:serve` | PocketBase для разработки |
|
| `pnpm pb:serve` | PocketBase для разработки |
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `guide/project-structure.md` | карта репозитория: `src/app`, `pocketbase/`, `tests/`, `scripts/`; где что искать |
|
| `guide/project-structure.md` | карта репозитория: `src/app`, `pocketbase/`, `tests/`, `scripts/`; где что искать |
|
||||||
| `guide/adding-a-feature.md` | пошаговый пример добавления новой сущности от миграции до экрана (на примере меток) |
|
| `guide/adding-a-feature.md` | пошаговый пример добавления новой сущности от миграции до экрана (на примере меток) |
|
||||||
| `guide/shared.md` | общая инфраструктура: клиент PocketBase, доменные ошибки, общие UI-компоненты |
|
| [guide/shared.md](guide/shared.md) | общая инфраструктура: клиент PocketBase, доменные ошибки, общие UI-компоненты |
|
||||||
| `guide/auth.md` | вход, регистрация, сессия, cookie, guard'ы, работа с текущим пользователем |
|
| `guide/auth.md` | вход, регистрация, сессия, cookie, guard'ы, работа с текущим пользователем |
|
||||||
| `guide/tags.md` | метки: репозиторий, диалог управления, компонент выбора |
|
| `guide/tags.md` | метки: репозиторий, диалог управления, компонент выбора |
|
||||||
| `guide/clients.md` | клиенты: телефоны, поиск, форма, точки расширения карточки |
|
| `guide/clients.md` | клиенты: телефоны, поиск, форма, точки расширения карточки |
|
||||||
|
|||||||
+11
-6
@@ -55,7 +55,8 @@ src/app/orders/
|
|||||||
|
|
||||||
| Папка | Назначение |
|
| Папка | Назначение |
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `shared/pocketbase/` | URL PocketBase, фабрика клиента, синхронизация cookie, маппинг ошибок |
|
| `shared/pocketbase/` | URL PocketBase, фабрика клиента, синхронизация cookie |
|
||||||
|
| `shared/errors/` | доменные ошибки и `toDomainError` |
|
||||||
| `users/` | сущность User (профиль владельца аккаунта), без логики входа |
|
| `users/` | сущность User (профиль владельца аккаунта), без логики входа |
|
||||||
| `auth/` | сессия: вход, регистрация, выход, текущий пользователь, guard'ы, формы входа/регистрации |
|
| `auth/` | сессия: вход, регистрация, выход, текущий пользователь, guard'ы, формы входа/регистрации |
|
||||||
| `layout/` | каркас приложения: шапка, навигация, меню пользователя |
|
| `layout/` | каркас приложения: шапка, навигация, меню пользователя |
|
||||||
@@ -117,14 +118,18 @@ providers: [
|
|||||||
|
|
||||||
## 4. Ошибки
|
## 4. Ошибки
|
||||||
|
|
||||||
`shared/pocketbase/` переводит `ClientResponseError` в доменные ошибки, которые понимает UI:
|
Доменные ошибки и `toDomainError(error: unknown): Error` живут в `shared/errors/`. `toDomainError` переводит `ClientResponseError` в доменные ошибки, которые понимает UI:
|
||||||
|
|
||||||
| Доменная ошибка | Когда |
|
| Доменная ошибка | Когда |
|
||||||
| ------------------- | -------------------------------------------------------------------------------- |
|
| ------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| `NotFoundError` | 404 (в том числе «чужая запись»: PocketBase не различает «нет» и «нет доступа») |
|
| `NotFoundError` | 404 (в том числе «чужая запись»: PocketBase не различает «нет» и «нет доступа») |
|
||||||
| `ValidationError` | 400 с ошибками по полям: `fieldErrors: Record<string, string>` (коды PocketBase) |
|
| `ValidationError` | 400 с ошибками по полям: `fieldErrors: Record<string, string>` (коды PocketBase) |
|
||||||
| `UnauthorizedError` | 401/403: сессия недействительна |
|
| `UnauthorizedError` | 401/403: сессия недействительна |
|
||||||
| `NetworkError` | нет связи / PocketBase недоступен |
|
| `NetworkError` | нет связи / PocketBase недоступен (`status === 0`) или запрос прерван |
|
||||||
|
| `InvalidCredentialsError` | неверный email или пароль; создаёт сервис аутентификации, `toDomainError` её не знает |
|
||||||
|
| `UnknownError` | всё остальное, в том числе 400 без ошибок по полям; исходная ошибка — в `cause` |
|
||||||
|
|
||||||
|
Все доменные ошибки наследуют `DomainError`: ошибка, уже являющаяся `DomainError`, возвращается из `toDomainError` как есть. Исходная ошибка PocketBase сохраняется в `cause` у каждой ошибки, которую создаёт `toDomainError`.
|
||||||
|
|
||||||
UI показывает ошибки полей рядом с полями, а общие ошибки — в области уведомлений с `role="alert"`.
|
UI показывает ошибки полей рядом с полями, а общие ошибки — в области уведомлений с `role="alert"`.
|
||||||
|
|
||||||
@@ -153,7 +158,7 @@ UI показывает ошибки полей рядом с полями, а
|
|||||||
|
|
||||||
На сервере каждый HTTP-запрос получает **свой экземпляр Angular-приложения и свой root-инжектор**. Всё, что живёт в DI, изолировано. Опасно только то, что живёт **вне DI** — на уровне модуля, общее для всех запросов.
|
На сервере каждый 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.
|
2. **Нельзя** хранить пользовательские данные в переменных модуля, статических полях классов, кэшах вне DI.
|
||||||
3. Сервер рендерит страницы с заголовками `Cache-Control: private, no-store` и `Vary: Cookie`: ни браузер, ни прокси не должны отдать одну страницу двум людям.
|
3. Сервер рендерит страницы с заголовками `Cache-Control: private, no-store` и `Vary: Cookie`: ни браузер, ни прокси не должны отдать одну страницу двум людям.
|
||||||
4. На сервере используется глобальный `fetch` без общего состояния; `authStore` — свой у каждого экземпляра PocketBase (не `LocalAuthStore`, а in-memory `BaseAuthStore`).
|
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_URL` — переменная окружения Node-сервера, внутренний адрес PocketBase для серверного рендера (по умолчанию `http://127.0.0.1:8090`). В браузер не передаётся.
|
||||||
- **Браузер:** `<window.location.origin>/pb`. Префикс `/pb` — константа `PB_PUBLIC_PATH` в `shared/pocketbase/`; если путь в nginx поменяется, меняется только она.
|
- **Браузер:** `<window.location.origin>/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.
|
- **Разработка:** `ng serve` проксирует `/pb/*` → `PB_URL` со срезанием префикса (`proxy.conf.mjs`). E2E-тесты production-сборки используют маленький тестовый фронт-прокси, повторяющий правила nginx.
|
||||||
|
|
||||||
### Ожидаемая конфигурация nginx (пример, деплой — вне MVP)
|
### Ожидаемая конфигурация nginx (пример, деплой — вне MVP)
|
||||||
|
|||||||
@@ -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<string>`: базовый адрес PocketBase. В браузере `<origin>/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<PocketBase>`: свой экземпляр на каждый инжектор приложения (на сервере — на каждый запрос). Фабрика одна и та же в браузере и на сервере.
|
||||||
|
- `providePocketBase()` — регистрирует `POCKETBASE` и `PB_URL` по умолчанию (браузерный: `<origin>/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<Tag[]> {
|
||||||
|
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).
|
||||||
+9
-1
@@ -35,7 +35,15 @@
|
|||||||
|
|
||||||
Почему цель builder'а, а не отдельный `vitest.integration.config.ts` с плагином: спеки собираются тем же компилятором Angular и с той же инициализацией TestBed, что и в `pnpm test`, без своего плагина компиляции и его настройки.
|
Почему цель 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 её не подключить, поэтому это отдельный набор.
|
||||||
|
|
||||||
## Контрактные тесты репозиториев
|
## Контрактные тесты репозиториев
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
files: ['**/*.html'],
|
||||||
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
|
extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
|
||||||
|
|||||||
+4
-3
@@ -6,9 +6,10 @@
|
|||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "pnpm test:lint && ng test",
|
||||||
"test:api": "tsc -p tsconfig.tests.json && vitest run --config vitest.api.config.mts",
|
"test:lint": "tsc -p tsconfig.tests.json && vitest run --config vitest.lint.config.mts",
|
||||||
"test:integration": "ng run pchelpcrm:test-integration",
|
"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": "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",
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
|||||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||||
import { appConfig } from './app.config';
|
import { appConfig } from './app.config';
|
||||||
import { serverRoutes } from './app.routes.server';
|
import { serverRoutes } from './app.routes.server';
|
||||||
|
import { providePbUrlForServer } from './shared/pocketbase/pb-url';
|
||||||
|
|
||||||
const serverConfig: ApplicationConfig = {
|
const serverConfig: ApplicationConfig = {
|
||||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
providers: [provideServerRendering(withRoutes(serverRoutes)), providePbUrlForServer()],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/
|
|||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideClientHydration } from '@angular/platform-browser';
|
import { provideClientHydration } from '@angular/platform-browser';
|
||||||
|
import { providePocketBase } from './shared/pocketbase/pocketbase-client';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideClientHydration(),
|
provideClientHydration(),
|
||||||
|
providePocketBase(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<string, string>,
|
||||||
|
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 {}
|
||||||
@@ -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<string, unknown> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { InjectionToken, Provider } from '@angular/core';
|
||||||
|
|
||||||
|
/** Путь, по которому браузер обращается к PocketBase на своём origin (nginx / прокси dev-сервера). */
|
||||||
|
export const PB_PUBLIC_PATH = '/pb';
|
||||||
|
|
||||||
|
/** Базовый адрес PocketBase: в браузере `<origin>/pb`, на сервере — `process.env.PB_URL`. */
|
||||||
|
export const PB_URL = new InjectionToken<string>('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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<PocketBase> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('в браузере — <origin>/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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>('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),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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<PocketBase>('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<string>('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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<string[]> {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// PocketBase SDK clients for tests; each helper returns its own instance with an in-memory auth store.
|
// 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 PocketBase, { BaseAuthStore } from 'pocketbase';
|
||||||
import { inject } from 'vitest';
|
import { inject } from 'vitest';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.d.ts", "src/**/*.spec.ts", "tests/support/provided-context.d.ts"]
|
"include": ["src/**/*.d.ts", "src/**/*.spec.ts", "tests/support/provided-context.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -8,5 +8,10 @@
|
|||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"files": [],
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user