docs: rewrite claude.md and readme in russian, add architecture docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
kamask
2026-10-25 12:35:01 +03:00
co-authored by Claude Sonnet 5.5
parent 0f69a2f8a5
commit 38b38de929
6 changed files with 686 additions and 154 deletions
+62
View File
@@ -0,0 +1,62 @@
# Документация разработчика
Здесь собрано всё, что нужно, чтобы разобраться в коде Pchelpcrm и начать его менять: решения, модель данных и руководство по каждой части приложения. Руководство растёт вместе с кодом: каждая новая фича добавляет или обновляет свою страницу.
## Основы
| Документ | О чём |
| --- | --- |
| [architecture.md](architecture.md) | общая схема, слои внутри фичи, DI, SSR и изоляция данных, nginx, UI-принципы |
| [data-model.md](data-model.md) | коллекции PocketBase, поля, правила доступа, публичный маршрут |
| [testing.md](testing.md) | уровни тестов, тестовый PocketBase, контрактные тесты |
## Руководство (`guide/`)
Страницы появляются вместе с соответствующим кодом.
| Страница | О чём |
| --- | --- |
| `guide/project-structure.md` | карта репозитория: `src/app`, `pocketbase/`, `tests/`, `scripts/`; где что искать |
| `guide/adding-a-feature.md` | пошаговый пример добавления новой сущности от миграции до экрана (на примере меток) |
| `guide/shared.md` | общая инфраструктура: клиент PocketBase, доменные ошибки, общие UI-компоненты |
| `guide/auth.md` | вход, регистрация, сессия, cookie, guard'ы, работа с текущим пользователем |
| `guide/tags.md` | метки: репозиторий, диалог управления, компонент выбора |
| `guide/clients.md` | клиенты: телефоны, поиск, форма, точки расширения карточки |
| `guide/orders.md` | заявки: статусы, фильтры, форма, вложения |
| `guide/share.md` | публичная ссылка и публичная страница |
| `guide/ssr.md` | как устроен серверный рендер: cookie, TransferState, что можно и нельзя на сервере |
| `guide/ui.md` | дизайн-токены, темы, общие компоненты, правила доступности |
## Как писать страницу руководства
Страница описывает часть приложения так, чтобы новый разработчик мог ей пользоваться, не читая весь код.
```markdown
# <Фича>
Одно-два предложения: что делает фича и для чего.
## Где лежит
Дерево папок фичи с пояснением каждой важной папки и файла.
## Публичный API
Что экспортирует `index.ts`: абстрактные классы, типы, компоненты (селектор, входы, выходы), функции `provideXxx()`, токены расширения. По каждому — одна строка назначения.
## Как пользоваться
Короткие примеры кода для типовых задач: получить данные, встроить компонент, обработать ошибку.
## Как расширить или заменить
Как подменить реализацию через DI, как подключиться к точке расширения, что поменять при добавлении поля.
## Тесты
Где лежат тесты фичи, какой контракт у репозитория, какие фейки доступны.
## Подводные камни
Неочевидное: ограничения SSR, правила доступа, особенности PocketBase.
```
Правила:
- Пиши о том, что есть в коде сейчас, а не о планах. Если код меняется — страница меняется в том же коммите.
- Примеры кода должны быть рабочими и короткими; полный код — ссылкой на файл.
- Не дублируй `architecture.md` и `data-model.md`: ссылайся на нужный раздел.
+219
View File
@@ -0,0 +1,219 @@
# Архитектура
Архитектурные решения проекта. Если код или новая задача им противоречит, сначала обсуждается и правится этот документ.
## 1. Общая схема
```
┌─ location /pb/ ─(префикс /pb срезается)─► PocketBase :8090
Браузер ──► nginx ──┤ ▲
└─ location / ──► Node SSR-сервер :4200 ────────┘
(src/server.ts) PB_URL — напрямую, без nginx
```
- Браузер ходит в PocketBase по пути `/pb/*` своего origin: SDK создаётся с адресом `<origin>/pb`, запрос `/pb/api/collections/...` nginx отправляет в PocketBase как `/api/collections/...`.
- Node SSR-сервер при рендере обращается к PocketBase напрямую по внутреннему адресу `PB_URL`.
- В разработке nginx нет: его роль для `/pb/*` выполняет прокси dev-сервера Angular (`proxy.conf.mjs`).
- **PocketBase** — единственный бэкенд: база, авторизация, файлы, правила доступа (API rules), кастомные маршруты (`pocketbase/pb_hooks/`). Это же и публичный API для будущих клиентов (мобильное приложение, Telegram-бот, десктоп).
- **Безопасность данных обеспечивает PocketBase**, а не фронтенд. Фронтенд может быть скомпрометирован или заменён другим клиентом, поэтому изоляция пользователей проверяется API-тестами на уровне правил коллекций.
- **SSR-сервер** только рендерит страницы и отдаёт статику, API PocketBase он не проксирует. Браузер всегда обращается к своему origin (nginx): нет CORS, cookie общая для приложения.
## 2. Слои и структура папок
Код организован по фичам (`src/app/<feature>/`). Внутри фичи — слои в духе DDD:
```
src/app/orders/
index.ts ← публичный API фичи: что можно импортировать другим фичам
orders.routes.ts ← маршруты фичи (lazy)
provide-orders.ts ← provideOrders(): регистрация реализаций в DI
domain/ ← чистый TypeScript, без Angular и без PocketBase
order.ts ← модель (типы)
order-status.ts ← статусы и подписи
order-filter.ts ← параметры фильтрации/поиска
order-repository.ts ← abstract class OrderRepository (контракт и DI-токен)
data/ ← реализация контрактов через PocketBase
pocketbase-order-repository.ts
order-mapper.ts ← запись PocketBase ⇄ доменная модель
ui/ ← компоненты (одна папка на компонент, если есть html/css)
order-list/order-list.ts
order-form/order-form.ts
testing/ ← фейки для тестов других слоёв и фич
in-memory-order-repository.ts
```
Правила:
1. **domain** не импортирует ничего из `@angular/*`, `pocketbase`, `data/`, `ui/`. Здесь только типы, чистые функции и абстрактные классы.
2. **data** знает про PocketBase и домен. Наружу отдаёт только доменные типы: запись PocketBase (`RecordModel`) не покидает слой `data`.
3. **ui** знает только домен и абстрактные классы. Компоненты никогда не импортируют `pocketbase` и `data/`.
4. Фича импортирует другую фичу **только через её `index.ts`**. Глубокие импорты (`../clients/data/...`) запрещены.
5. Общая инфраструктура, нужная всем фичам, живёт в `src/app/shared/` (например `shared/pocketbase/`, позже `shared/ui/`). Туда кладём только то, что реально используется двумя и более фичами.
### Фичи MVP
| Папка | Назначение |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `shared/pocketbase/` | URL PocketBase, фабрика клиента, синхронизация cookie, маппинг ошибок |
| `users/` | сущность User (профиль владельца аккаунта), без логики входа |
| `auth/` | сессия: вход, регистрация, выход, текущий пользователь, guard'ы, формы входа/регистрации |
| `layout/` | каркас приложения: шапка, навигация, меню пользователя |
| `home/` | главная страница: лендинг для гостя или список заявок для вошедшего |
| `tags/` | метки пользователя: диалог управления метками и компонент выбора меток |
| `clients/` | клиенты |
| `orders/` | заявки, вложения |
| `share/` | публичная ссылка и публичная страница заявки |
| `not-found/` | страница 404 |
### Направление зависимостей между фичами
```
home ──► orders ──► clients ──► shared
│ │ └───► tags ─────► shared
│ └──────► share ────► shared
└──► auth ──► users
```
- Зависимости идут только в одну сторону, циклов нет.
- Если «нижней» фиче нужно показать что-то из «верхней» (например, история заявок в карточке клиента), нижняя объявляет точку расширения — `InjectionToken` со списком компонентов, — а верхняя регистрирует в ней свой компонент в `provideXxx()`. Так `clients` ничего не знает о заявках.
> Сущность заявки в коде называется **`Order`**; коллекция, папка и маршруты — `orders`.
## 3. Dependency Injection через абстрактные классы
Каждый репозиторий и сервис, у которого может появиться другая реализация, описан **абстрактным классом**. Абстрактный класс одновременно контракт и DI-токен.
```ts
// tags/domain/tag-repository.ts
export abstract class TagRepository {
abstract list(): Promise<Tag[]>;
abstract create(draft: TagDraft): Promise<Tag>;
abstract update(id: string, changes: Partial<TagDraft>): Promise<Tag>;
abstract delete(id: string): Promise<void>;
}
// tags/provide-tags.ts
export function provideTags(): Provider[] {
return [{ provide: TagRepository, useClass: PocketBaseTagRepository }];
}
// app.config.ts — единственное место, где выбираются реализации
providers: [
providePocketBase(),
provideAuth(),
provideTags(),
provideClients(),
provideOrders(),
provideShare(),
];
```
- Компоненты и сервисы получают зависимость так: `private readonly tags = inject(TagRepository);`.
- Чтобы поменять реализацию, достаточно изменить `provideXxx()` или `app.config.ts`.
- В unit-тестах вместо PocketBase подставляется фейк из `testing/`: `{ provide: TagRepository, useClass: InMemoryTagRepository }`.
- Методы репозиториев возвращают `Promise` (PocketBase SDK промис-ориентирован). В компонентах данные загружаются через `resource()`: так SSR дожидается загрузки (resource регистрирует pending task), а состояние доступно как сигналы.
- Отдельного слоя сторов нет. Стор (сервис с состоянием на сигналах) заводится только там, где одно состояние реально разделяют несколько компонентов, например список меток для диалога управления, компонента выбора и фильтров.
## 4. Ошибки
`shared/pocketbase/` переводит `ClientResponseError` в доменные ошибки, которые понимает UI:
| Доменная ошибка | Когда |
| ------------------- | -------------------------------------------------------------------------------- |
| `NotFoundError` | 404 (в том числе «чужая запись»: PocketBase не различает «нет» и «нет доступа») |
| `ValidationError` | 400 с ошибками по полям: `fieldErrors: Record<string, string>` (коды PocketBase) |
| `UnauthorizedError` | 401/403: сессия недействительна |
| `NetworkError` | нет связи / PocketBase недоступен |
UI показывает ошибки полей рядом с полями, а общие ошибки — в области уведомлений с `role="alert"`.
## 5. Аутентификация
- Вход и регистрация по email и паролю через коллекцию `users` (PocketBase auth). Регистрация открытая. Подтверждение email и сброс пароля в MVP не делаются.
- Токен PocketBase (JWT) хранится в **cookie `pb_auth`** (`Path=/`, `SameSite=Lax`, `Secure` на HTTPS). Cookie нужна, чтобы SSR-сервер знал, кто пришёл.
- Cookie **не `HttpOnly`**: браузерному SDK нужен токен для прямых запросов в `/pb/api`. Риск — кража токена при XSS. Снижаем его так: Angular экранирует шаблоны, нигде не используется `innerHTML` с пользовательскими данными, позже добавим CSP. Это осознанный компромисс MVP.
- В браузере: при изменении `authStore` cookie перезаписывается (`exportToCookie`); при старте приложения, если токен есть, вызывается `authRefresh()`; при ошибке — выход.
- На сервере: токен читается из заголовка `Cookie` текущего запроса (`loadFromCookie`). Сервер cookie не пишет и токен не обновляет.
## 6. SSR и изоляция данных пользователей
### Какие маршруты как рендерятся
| Маршрут | Режим | Почему |
| --------------------------------------- | ------ | ------------------------------------------------- |
| `/` | Server | лендинг для гостя или список заявок для вошедшего |
| `/orders/:id` | Server | карточка заявки |
| `/p/:token` | Server | публичная страница заявки (превью в мессенджерах) |
| остальные (`/clients/**`, формы заявок) | Client | интерактивные формы, SSR не даёт выгоды |
Настраивается в `src/app/app.routes.server.ts`.
### Правила, без которых данные пользователей могут перемешаться
На сервере каждый HTTP-запрос получает **свой экземпляр Angular-приложения и свой root-инжектор**. Всё, что живёт в DI, изолировано. Опасно только то, что живёт **вне DI** — на уровне модуля, общее для всех запросов.
1. **Нельзя** создавать PocketBase на уровне модуля (`export const pb = new PocketBase(...)`). Экземпляр создаёт только фабрика в `shared/pocketbase/` внутри DI. ESLint-правило запрещает `new PocketBase(` во всех остальных файлах.
2. **Нельзя** хранить пользовательские данные в переменных модуля, статических полях классов, кэшах вне DI.
3. Сервер рендерит страницы с заголовками `Cache-Control: private, no-store` и `Vary: Cookie`: ни браузер, ни прокси не должны отдать одну страницу двум людям.
4. На сервере используется глобальный `fetch` без общего состояния; `authStore` — свой у каждого экземпляра PocketBase (не `LocalAuthStore`, а in-memory `BaseAuthStore`).
5. Защищённые файлы (вложения) не рендерятся на сервере: их URL содержит короткоживущий токен, ему не место в HTML.
6. Изоляция проверяется e2e-тестами: параллельные SSR-запросы двух пользователей не видят данных друг друга.
### Гидратация
PocketBase SDK использует `fetch`, а не `HttpClient`, поэтому встроенный HTTP transfer cache Angular не работает. Чтобы после гидратации не загружать данные второй раз, используется `TransferState`.
## 7. URL PocketBase
- **Сервер:** `PB_URL` — переменная окружения Node-сервера, внутренний адрес PocketBase для серверного рендера (по умолчанию `http://127.0.0.1:8090`). В браузер не передаётся.
- **Браузер:** `<window.location.origin>/pb`. Префикс `/pb` — константа `PB_PUBLIC_PATH` в `shared/pocketbase/`; если путь в nginx поменяется, меняется только она.
- Всё это задаётся в одном месте: `shared/pocketbase/` (InjectionToken `PB_URL`, разные провайдеры для сервера и браузера).
- **Разработка:** `ng serve` проксирует `/pb/*` → `PB_URL` со срезанием префикса (`proxy.conf.mjs`). E2E-тесты production-сборки используют маленький тестовый фронт-прокси, повторяющий правила nginx.
### Ожидаемая конфигурация nginx (пример, деплой — вне MVP)
```nginx
location /pb/ {
proxy_pass http://127.0.0.1:8090/; # слэш в конце срезает префикс /pb
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # для realtime (SSE) PocketBase
proxy_read_timeout 360s;
client_max_body_size 60M; # один файл вложения до 50 МБ + запас
}
location / {
proxy_pass http://127.0.0.1:4000; # Node SSR-сервер
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
- Вложения загружаются по одному файлу на запрос, чтобы укладываться в `client_max_body_size`.
- В настройках PocketBase нужно указать доверенный заголовок реального IP (`X-Real-IP`), иначе лимиты запросов и логи будут видеть IP nginx.
- Админку PocketBase удобнее открывать напрямую на порту 8090 (через SSH-туннель), а не через `/pb/_/`.
## 8. Формы и UI
- Формы — Signal Forms (`@angular/forms/signals`) со схемами валидации.
- Стили — чистый CSS: дизайн-токены в CSS custom properties (`src/styles.css`), тёмная тема по `prefers-color-scheme`, никаких препроцессоров и CSS-фреймворков.
- Префикс селекторов компонентов и директив — `ksk` (`ksk-order-list`, `kskAutofocus`).
- Минимальная нагрузка на GPU — приложение должно быть лёгким на слабых телефонах и ноутбуках:
- анимируются только `transform` и `opacity`, коротко (до 200 мс); при `prefers-reduced-motion: reduce` анимации отключаются;
- без `backdrop-filter`, `filter: blur()`, больших и многослойных `box-shadow`, анимированных градиентов, `background-attachment: fixed`;
- `will-change` — только точечно и временно, не «на всякий случай»;
- тени — одна мягкая тень из токенов; глубину лучше передавать фоном поверхности и границей;
- длинные списки — `content-visibility: auto` с `contain-intrinsic-size`, чтобы не рендерить невидимое.
- Интерфейс только на русском, без i18n-фреймворка. Даты и суммы форматируются в локали `ru-RU`, валюта — рубли.
- Mobile-first: мастер часто работает с телефона.
- Доступность: AXE без ошибок, WCAG AA.
## 9. Что сознательно отложено
Подтверждение email и сброс пароля, инвайты, публичный профиль мастера, отчёты и финансы, напоминания, импорт/экспорт, Docker и деплой, мобильное приложение, бот. Архитектура их не блокирует: всё общение с данными идёт через PocketBase API и его правила.
+148
View File
@@ -0,0 +1,148 @@
# Модель данных (PocketBase)
Схема создаётся JS-миграциями в `pocketbase/pb_migrations/` (одна миграция на изменение, пишется руками, не через админку). Кастомные маршруты и хуки — в `pocketbase/pb_hooks/`.
У всех коллекций, кроме `users`, есть поле `owner` — ссылка на владельца. Это основа изоляции: любые правила доступа начинаются с `owner = @request.auth.id`.
Поля `id`, `created`, `updated` (autodate) есть у всех коллекций и ниже не перечисляются.
## users (auth-коллекция, уже существует в PocketBase)
| Поле | Тип | Ограничения |
| --- | --- | --- |
| `email` | email | обязательно, уникально |
| `password` | password | минимум 8 символов |
| `name` | text | необязательно, до 100 символов |
| `verified` | bool | системное; гость не может выставить `true` |
Способ входа — только email + пароль (OAuth2, OTP, MFA выключены).
| Правило | Значение | Смысл |
| --- | --- | --- |
| list / view | `id = @request.auth.id` | пользователь видит только себя |
| create | `""` (всем) | открытая регистрация |
| update | `id = @request.auth.id` | меняет только себя |
| delete | `null` (только суперпользователь) | удаление аккаунта в MVP не делаем |
Поле `avatar`, созданное PocketBase по умолчанию, можно оставить, в MVP оно не используется.
## tags — метки
Метки заменяют и категории, и источники заявок: «Установка Windows», «YouDo», «Профи.ру», «Сарафан», «Гарантия» — это всё метки. Набор у каждого пользователя свой.
| Поле | Тип | Ограничения |
| --- | --- | --- |
| `owner` | relation → users | обязательно, одна, `cascadeDelete: true` |
| `name` | text | обязательно, 1–50 символов |
| `color` | select | одно из: `gray`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`; обязательно |
Индекс: уникальный `(owner, name COLLATE NOCASE)` — у одного пользователя нет двух меток с одинаковым названием без учёта регистра; у разных пользователей названия могут совпадать.
Цвет — ключ из фиксированной палитры, а не произвольный HEX: так UI гарантирует контраст AA в светлой и тёмной теме.
## clients — клиенты
| Поле | Тип | Ограничения |
| --- | --- | --- |
| `owner` | relation → users | обязательно, `cascadeDelete: true` |
| `name` | text | обязательно, 1–200 символов (как клиента зовут или как он подписан на площадке) |
| `phones` | json | массив нормализованных номеров (`["79991234567"]`), до 5 штук, можно пустой |
| `email` | email | необязательно |
| `contacts` | text | свободный текст до 2000 символов: мессенджеры и прочие контакты |
| `external_urls` | json | необязательно; массив ссылок на профили клиента на любых площадках (YouDo, Профи.ру, Avito и др.), до 5 штук, каждая — валидный `http(s)` URL |
| `address` | text | необязательно, до 500 символов |
| `note` | text | внутренняя заметка, до 5000 символов |
Нормализация телефона (делает фронтенд, функция в `clients/domain`): оставить только цифры; `8XXXXXXXXXX` → `7XXXXXXXXXX`; 10 цифр, начинающиеся на `9`, → добавить `7`. Остальные номера хранятся как есть (только цифры).
Удалить клиента, у которого есть заявки, нельзя (связь `orders.client` обязательная и без каскада): сначала удаляются или переносятся заявки.
## orders — заявки
| Поле | Тип | Ограничения |
| --- | --- | --- |
| `owner` | relation → users | обязательно, `cascadeDelete: true` |
| `client` | relation → clients | обязательно, одна, без каскада |
| `title` | text | обязательно, 1–200 символов |
| `description` | text | описание проблемы со слов клиента, до 10000 |
| `status` | select | обязательно: `new`, `scheduled`, `in_progress`, `done`, `failed` |
| `scheduled_at` | date | дата и время визита, необязательно |
| `address` | text | адрес выезда, до 500 (по умолчанию в форме подставляется адрес клиента) |
| `price` | number | целые рубли, ≥ 0, необязательно |
| `result` | text | что сделано, до 10000; **виден по публичной ссылке** |
| `note` | text | примечания мастера, до 10000; **видны по публичной ссылке** |
| `review` | text | отзыв клиента (например, с площадки), до 5000; **виден по публичной ссылке** |
| `external_url` | url | необязательно; ссылка на страницу заказа на любой площадке (YouDo, Профи.ру, Avito и др.) |
| `tags` | relation → tags | несколько, до 20, необязательно |
| `attachments` | file | несколько, до 20 файлов, до 50 МБ каждый, **protected**; изображения (`image/jpeg`, `image/png`, `image/webp`, `image/heic`) и аудио (`audio/mpeg`, `audio/mp4`, `audio/x-m4a`, `audio/aac`, `audio/ogg`, `audio/wav`, `audio/webm`, `audio/amr`); миниатюры `200x200` |
| `share_token` | text | необязательно; если задан — 43 символа `[A-Za-z0-9_-]`; уникален среди непустых |
Статусы и подписи в UI:
| Значение | Подпись |
| --- | --- |
| `new` | Новая |
| `scheduled` | Запланирована |
| `in_progress` | В работе |
| `done` | Выполнена |
| `failed` | Не выполнена |
Переходы между статусами не ограничены: мастер может выставить любой.
Индексы: `owner`, `(owner, status)`, `(owner, created)`, уникальный частичный `share_token WHERE share_token != ''`.
### Соответствие полям YouDo (для справки)
| YouDo | Pchelpcrm |
| --- | --- |
| title, description | `title`, `description` |
| price | `price` |
| address, privateInfo | `address` |
| customerName | `clients.name` |
| customerUrl | `clients.external_urls` |
| taskUrl | `external_url` |
| category | метка |
| review | `review` |
| taskDate | `created` / `scheduled_at` |
## Правила доступа для tags, clients, orders
Общий шаблон (для `orders` добавляются проверки связей):
| Правило | Значение |
| --- | --- |
| list / view | `owner = @request.auth.id` |
| create | `@request.auth.id != "" && owner = @request.auth.id` |
| update | `owner = @request.auth.id && (@request.body.owner:isset = false \|\| @request.body.owner = @request.auth.id)` |
| delete | `owner = @request.auth.id` |
Дополнительно для `orders` (create и update):
- клиент принадлежит тому же пользователю: `client.owner = @request.auth.id`;
- все метки принадлежат тому же пользователю (для пустого списка условие выполняется).
Если какое-то условие невозможно выразить правилом, его реализуют хуком в `pocketbase/pb_hooks/`. Решение принимается при реализации, поведение фиксируют тесты.
### Как PocketBase отвечает на нарушения правил
API-тесты проверяют именно это поведение. Если фактический код в PocketBase 0.40 окажется другим, тесты фиксируют фактический, а главное требование неизменно: **чужие данные никогда не попадают в ответ и не меняются**.
| Операция | Правило не выполнено | Правило `null` (не суперпользователь) |
| --- | --- | --- |
| list | 200, чужие записи отфильтрованы | 403 |
| view | 404 | 403 |
| create | 400 | 403 |
| update | 404 | 403 |
| delete | 404 | 403 |
## Публичный маршрут
`GET /api/public/orders/{token}` (хук в `pocketbase/pb_hooks/`), без авторизации.
- Ищет заявку по непустому `share_token`, совпадающему точно.
- Ответ 200 содержит **только**: `title`, `description`, `result`, `note`, `review`, `status`, `scheduled_at`, `price`, `created`, `tags: [{ name, color }]`.
- Никогда не содержит: `id` заявки, `owner`, данные клиента (имя, телефоны, контакты, адрес, ссылки на профили), `address` заявки, `external_url`, `attachments`, `share_token`, email мастера.
- Нет заявки или токен пустой/неверного формата — 404 с одинаковым телом.
- Заголовки: `Cache-Control: no-store`, `X-Robots-Tag: noindex`.
Ссылку создаёт владелец, записывая в `share_token` случайную строку (32 случайных байта в base64url, сгенерированные в браузере через `crypto.getRandomValues`). Отключить ссылку — записать пустую строку; перевыпустить — записать новую.
+56
View File
@@ -0,0 +1,56 @@
# Тестирование
Разработка ведётся по TDD: тесты пишутся до реализации.
## Уровни тестов
| Уровень | Что проверяет | Где лежит | Команда |
| --- | --- | --- | --- |
| **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` |
Команды появляются по мере того, как настраиваются соответствующие стенды.
## Контрактные тесты репозиториев
У каждого репозитория две реализации: PocketBase (`data/`) и фейк в памяти (`testing/`). Чтобы фейк не расходился с реальностью, поведение описывается **один раз**:
```ts
// tags/testing/tag-repository.contract.ts
export function describeTagRepositoryContract(
setup: () => Promise<{ repoA: TagRepository; repoB: TagRepository }>,
) {
describe('контракт TagRepository', () => {
it('не показывает пользователю B метки пользователя A', async () => { /* ... */ });
});
}
```
и вызывается дважды: в `in-memory-tag-repository.spec.ts` (быстро, в `pnpm test`) и в `pocketbase-tag-repository.integration.spec.ts` (на тестовом PocketBase, в `pnpm test:integration`).
## Тестовый PocketBase
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`; миграции применяются при старте.
4. Ожидание готовности по `GET /api/health`.
5. После прогона процесс останавливается, папка удаляется.
Код запуска один на все уровни: `tests/support/pocketbase-server.ts`.
## Правила написания тестов
- **Каждый тест создаёт своих пользователей** с уникальными email (`user-<случайная строка>@test.local`). Тесты не зависят друг от друга и от порядка, поэтому могут идти параллельно и не нуждаются в очистке.
- Хелперы (`tests/support/`): `createUser()`, `asUser(user)`, `asGuest()`, `asSuperuser()` возвращают готовый экземпляр PocketBase SDK.
- **Изоляцию проверяем всегда двумя пользователями**: A создаёт данные, B пытается их прочитать, найти через list/filter, изменить, удалить, сослаться на них. Плюс гость.
- Тест проверяет поведение, а не реализацию: статус ответа, тело, состояние после операции (перечитать суперпользователем).
- Unit-тесты компонентов используют фейки из `<feature>/testing/` и не обращаются к сети.
- Компонентные тесты ищут элементы по ролям и подписям (как пользователь и скринридер), а не по CSS-классам.
- E2E-тесты: каждый сценарий регистрирует своего пользователя через UI или API; на ключевых страницах выполняется проверка axe (`@axe-core/playwright`) без нарушений.
- Названия тестов на русском и описывают поведение: `it('не отдаёт гостю чужую метку')`.
- Пока реализации нет, тесты должны компилироваться: для этого создаются минимальные заглушки контрактов (типы, абстрактные классы, методы с `throw new Error('Not implemented')`). Новые тесты падают из-за невыполненных ожиданий, а не из-за ошибок импорта или стенда.