From 89782315d7cb038d1ad539241407b412cc58af3f Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 25 May 2026 01:00:15 +0000 Subject: [PATCH 01/13] chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8 --- backend/UniVerse.Api/UniVerse.Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj index 5102384..22a9ab9 100644 --- a/backend/UniVerse.Api/UniVerse.Api.csproj +++ b/backend/UniVerse.Api/UniVerse.Api.csproj @@ -17,7 +17,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From fa7cce49621bf99aaf23f9bc876f50582221882e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 25 May 2026 01:00:16 +0000 Subject: [PATCH 02/13] chore(deps): update dependency microsoft.aspnetcore.mvc.testing to 10.0.8 --- backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj index c3a346e..4dae84e 100644 --- a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj +++ b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj @@ -9,7 +9,7 @@ - + From 7e782338b90e37cddf472972c4f97c571fd8309d Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 25 May 2026 05:00:32 +0000 Subject: [PATCH 03/13] chore(deps): update dependency microsoft.entityframeworkcore.design to 10.0.8 --- backend/UniVerse.Api/UniVerse.Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj index 22a9ab9..1f9a164 100644 --- a/backend/UniVerse.Api/UniVerse.Api.csproj +++ b/backend/UniVerse.Api/UniVerse.Api.csproj @@ -18,7 +18,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From f03b410e8fec56407f629c2450f174cd417c56c4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 26 May 2026 05:00:33 +0000 Subject: [PATCH 04/13] chore(deps): update dependency microsoft.entityframeworkcore.inmemory to 10.0.8 --- backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj index 4dae84e..afc14e8 100644 --- a/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj +++ b/backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj @@ -10,7 +10,7 @@ - + From f7496bab719e0abfcf82ca87e728b1c3894be5b2 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 26 May 2026 05:00:34 +0000 Subject: [PATCH 05/13] chore(deps): update dependency microsoft.extensions.configuration.abstractions to 10.0.8 --- backend/UniVerse.Application/UniVerse.Application.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/UniVerse.Application/UniVerse.Application.csproj b/backend/UniVerse.Application/UniVerse.Application.csproj index d068bc7..cef0f10 100644 --- a/backend/UniVerse.Application/UniVerse.Application.csproj +++ b/backend/UniVerse.Application/UniVerse.Application.csproj @@ -9,7 +9,7 @@ - + From b80a30d6a10e56a836fbd8fe777cc6ede6e4ad4e Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 27 May 2026 05:00:38 +0000 Subject: [PATCH 06/13] chore(deps): update dependency microsoft.extensions.http to 10.0.8 --- backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj index fa24205..1704d9f 100644 --- a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -10,7 +10,7 @@ - + From 3ec0dec2297f23ace6cfa079e2810ddf85271baa Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 28 May 2026 05:00:17 +0000 Subject: [PATCH 07/13] chore(deps): update dependency npgsql.entityframeworkcore.postgresql to 10.0.2 --- backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj index 1704d9f..193ece1 100644 --- a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -9,7 +9,7 @@ - + From cb80b35ba6225d998d0cea0a2c8487c4c8bf183f Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 05:04:31 +0300 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20k6=20=D1=82=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/k6-report-2026-05-28.md | 120 +++++++++++++++++++++++++ docs/load-testing-k6.md | 94 +++++++++++++++++++ frontend/scripts/loadtest-endpoints.js | 66 ++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 docs/k6-report-2026-05-28.md create mode 100644 docs/load-testing-k6.md create mode 100644 frontend/scripts/loadtest-endpoints.js diff --git a/docs/k6-report-2026-05-28.md b/docs/k6-report-2026-05-28.md new file mode 100644 index 0000000..a24c7a6 --- /dev/null +++ b/docs/k6-report-2026-05-28.md @@ -0,0 +1,120 @@ +# Отчет по нагрузочному тестированию k6 + +Дата отчета: 2026-05-28 + +## Объект тестирования + +- Стенд: `https://universe.zetcraft.ru` +- Скрипт: [`frontend/scripts/loadtest-endpoints.js`](../frontend/scripts/loadtest-endpoints.js) +- Endpoint'ы: + - `GET /api/v1/courses` + - `GET /api/v1/lectures` + - `GET /api/v1/users/me/stats` + +## Профиль нагрузки + +Тест запускался в 3 параллельных сценариях: + +- `courses_list` +- `lectures_list` +- `user_stats` + +Для каждого сценария использовалось `30 VU`, итого максимум `90 VU`. +Длительность активной нагрузки каждого сценария: `15s`. +С учетом `gracefulStop` максимальная длительность выполнения составила `45s`. + +## Оборудование и сеть + +Тест запускался с машины со следующей конфигурацией: + +- CPU: AMD Ryzen 7 8845HS, `10` потоков использовалось для нагрузки. +- RAM: DDR5 5600, `10 GB` доступно. +- Накопитель: NVMe SSD. +- Сеть: `1 Gbit/s`. + +## Критерии прохождения + +- `checks: rate > 0.95` +- `http_req_duration: p(95) < 1500ms` +- `http_req_failed: rate < 0.01` + +## Прогон 1: без паузы между итерациями + +Команда запуска: + +```bash +BASE_URL="https://universe.zetcraft.ru" VUS=30 DURATION="15s" PAUSE_SECONDS=0 k6 run ./frontend/scripts/loadtest-endpoints.js +``` + +### Итоги + +| Метрика | Значение | +| --- | ---: | +| Статус threshold'ов | пройдено | +| Успешность checks | 100.00% | +| Ошибки HTTP | 0.00% | +| Всего HTTP-запросов | 3508 | +| RPS | 77.95 req/s | +| `http_req_duration` avg | 769.41ms | +| `http_req_duration` med | 38.67ms | +| `http_req_duration` p(90) | 66.61ms | +| `http_req_duration` p(95) | 93.63ms | +| `http_req_duration` max | 36.14s | +| Всего итераций | 3508 | +| Прерванные итерации | 19 | +| Получено данных | 47 MB | +| Отправлено данных | 2.1 MB | + +Проверки: + +- `status is 200`: успешно. +- `body is not empty`: успешно. + +## Прогон 2: пауза 1 секунда между итерациями + +Команда запуска: + +```bash +BASE_URL="https://universe.zetcraft.ru" VUS=30 DURATION="15s" PAUSE_SECONDS=1 k6 run ./frontend/scripts/loadtest-endpoints.js +``` + +### Итоги + +| Метрика | Значение | +| --- | ---: | +| Статус threshold'ов | пройдено | +| Успешность checks | 100.00% | +| Ошибки HTTP | 0.00% | +| Всего HTTP-запросов | 895 | +| RPS | 19.89 req/s | +| `http_req_duration` avg | 336.11ms | +| `http_req_duration` med | 11.77ms | +| `http_req_duration` p(90) | 32.01ms | +| `http_req_duration` p(95) | 42.19ms | +| `http_req_duration` max | 35.9s | +| Всего итераций | 895 | +| Прерванные итерации | 43 | +| Получено данных | 12 MB | +| Отправлено данных | 675 kB | + +Проверки: + +- `status is 200`: успешно. +- `body is not empty`: успешно. + +## Сравнение прогонов + +| Параметр | Без паузы | Пауза 1s | +| --- | ---: | ---: | +| HTTP-запросов | 3508 | 895 | +| RPS | 77.95 req/s | 19.89 req/s | +| Ошибки HTTP | 0.00% | 0.00% | +| Checks | 100.00% | 100.00% | +| p(95) | 93.63ms | 42.19ms | +| Максимальная задержка | 36.14s | 35.9s | + +## Вывод + +Оба прогона успешно прошли заданные threshold'ы: ошибок HTTP не зафиксировано, все проверки ответов успешны, `p(95)` существенно ниже порога `1500ms`. + +При запуске без паузы стенд обработал около `77.95 req/s`, при паузе `1s` - около `19.89 req/s`. Во всех прогонах наблюдались единичные длинные запросы до `35-36s`, при этом они не повлияли на прохождение p95-порога. Это стоит учитывать при дальнейшем анализе хвостовых задержек. diff --git a/docs/load-testing-k6.md b/docs/load-testing-k6.md new file mode 100644 index 0000000..bbd5bb5 --- /dev/null +++ b/docs/load-testing-k6.md @@ -0,0 +1,94 @@ +# Базовый нагрузочный тест (k6) для 3 крупных GET endpoint'ов + +## Цель теста + +Проверить, что при небольшой параллельной нагрузке API: + +- отвечает без ошибок; +- сохраняет приемлемую задержку на «тяжелых» чтениях; +- не падает на endpoint пользовательской статистики. + +Тест рассчитан на новичка: один скрипт, простые пороги, быстрый запуск. + +## Какие endpoint используются + +В тест включены: + +1. `GET /api/v1/courses` — крупный список данных. +2. `GET /api/v1/lectures` — крупный список данных. +3. `GET /api/v1/users/me/stats` — endpoint с информацией о пользователе. + +## Файл теста + +- [loadtest-endpoints.js](../frontend/scripts/loadtest-endpoints.js) + +## Предусловия перед запуском + +1. Запущен API (локально или на тестовом стенде). +2. Если endpoint'ы требуют авторизацию — есть валидный JWT токен. +3. Установлен k6. + +## Запуск + +Без параметров (локальный API по умолчанию `http://localhost:5019`): + +```bash +k6 run ./frontend/scripts/loadtest-endpoints.js +``` + +С параметрами окружения: + +```bash +export TOKEN="" +BASE_URL="http://localhost:5019" VUS=15 DURATION="2m" PAUSE_SECONDS=0 k6 run ./frontend/scripts/loadtest-endpoints.js +``` + +## Что именно делает тест + +Скрипт запускает **3 параллельных сценария**: + +- `courses_list` +- `lectures_list` +- `user_stats` + +Параметры каждого сценария: + +- executor: `constant-vus` +- нагрузка: `10 VU` +- длительность: `2m` +- пауза между итерациями: `sleep(0.5)` + +Итого базовый запуск создает до `30 VU` одновременно: по `10 VU` на каждый из 3 сценариев. + +Переменные окружения: + +- `VUS` — количество VU на каждый сценарий, по умолчанию `10`. +- `DURATION` — длительность каждого сценария, по умолчанию `2m`. +- `PAUSE_SECONDS` — пауза между итерациями, по умолчанию `0.5`. + +На каждом запросе проверяется: + +- статус ответа `200`; +- тело ответа не пустое. + +## Пороговые значения (pass/fail) + +- `http_req_failed: rate < 0.01` — ошибок менее 1%. +- `http_req_duration: p(95) < 1500` — 95% запросов быстрее 1.5с. +- `checks: rate > 0.95` — минимум 95% проверок успешны. + +Если любой threshold не выполнен, k6 завершит запуск как failed. + +## Как интерпретировать результат + +После прогона посмотрите в summary: + +1. `http_req_failed` — если выше 1%, есть проблема со стабильностью. +2. `http_req_duration p(95)` — если выше 1500ms, есть деградация по задержке. +3. `checks` — если ниже 95%, часть ответов не прошла базовую валидацию. + +Минимальный формальный вывод для отчета: + +- «Проведен базовый нагрузочный прогон k6 (3 endpoint'а, 10 VU на сценарий, 5 минут).» +- «Критерии: ошибки < 1%, p95 < 1500ms, checks > 95%.» +- «Статус: пройдено / не пройдено по итогам summary.» diff --git a/frontend/scripts/loadtest-endpoints.js b/frontend/scripts/loadtest-endpoints.js new file mode 100644 index 0000000..5dc0216 --- /dev/null +++ b/frontend/scripts/loadtest-endpoints.js @@ -0,0 +1,66 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:5019'; +const TOKEN = __ENV.TOKEN || ''; +const VUS = Number(__ENV.VUS || 10); +const DURATION = __ENV.DURATION || '2m'; +const PAUSE_SECONDS = Number(__ENV.PAUSE_SECONDS || 0.5); + +const headers = TOKEN + ? { Authorization: `Bearer ${TOKEN}` } + : {}; + +export const options = { + scenarios: { + courses_list: { + executor: 'constant-vus', + vus: VUS, + duration: DURATION, + exec: 'coursesList', + }, + lectures_list: { + executor: 'constant-vus', + vus: VUS, + duration: DURATION, + exec: 'lecturesList', + }, + user_stats: { + executor: 'constant-vus', + vus: VUS, + duration: DURATION, + exec: 'userStats', + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<1500'], + checks: ['rate>0.95'], + }, +}; + +function request(path, tag) { + const res = http.get(`${BASE_URL}${path}`, { + headers, + tags: { endpoint: tag }, + }); + + check(res, { + 'status is 200': (r) => r.status === 200, + 'body is not empty': (r) => (r.body || '').length > 0, + }); + + sleep(PAUSE_SECONDS); +} + +export function coursesList() { + request('/api/v1/courses?page=1&pageSize=50', 'courses_list'); +} + +export function lecturesList() { + request('/api/v1/lectures?page=1&pageSize=50', 'lectures_list'); +} + +export function userStats() { + request('/api/v1/users/me/stats', 'user_stats'); +} From 88146f22b64401452d03f6cb8e94ce3e198992b8 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 19:17:11 +0300 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=81=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20Playwright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/frontend-playwright.yml | 40 ++++++++ docs/playwright-tests.md | 111 +++++++++++++++++++++++ frontend/.gitignore | 5 + frontend/package.json | 9 +- frontend/playwright.config.ts | 32 +++++++ frontend/pnpm-lock.yaml | 38 ++++++++ frontend/tests/e2e/auth.spec.ts | 9 ++ frontend/tests/e2e/catalog.spec.ts | 23 +++++ frontend/tests/e2e/support/mockApi.ts | 74 +++++++++++++++ frontend/tests/mocks/fixtures.ts | 80 ++++++++++++++++ 10 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/frontend-playwright.yml create mode 100644 docs/playwright-tests.md create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tests/e2e/auth.spec.ts create mode 100644 frontend/tests/e2e/catalog.spec.ts create mode 100644 frontend/tests/e2e/support/mockApi.ts create mode 100644 frontend/tests/mocks/fixtures.ts diff --git a/.gitea/workflows/frontend-playwright.yml b/.gitea/workflows/frontend-playwright.yml new file mode 100644 index 0000000..e3e0bbc --- /dev/null +++ b/.gitea/workflows/frontend-playwright.yml @@ -0,0 +1,40 @@ +name: Frontend Playwright + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + e2e: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Build app + run: pnpm build-only + + - name: Install Playwright browser + run: pnpm exec playwright install --with-deps chromium + + - name: Run e2e + run: pnpm test:e2e diff --git a/docs/playwright-tests.md b/docs/playwright-tests.md new file mode 100644 index 0000000..9070b1e --- /dev/null +++ b/docs/playwright-tests.md @@ -0,0 +1,111 @@ +# Playwright E2E тесты frontend + +## Назначение + +Playwright-тесты проверяют ключевые браузерные сценарии frontend-приложения UniVerse: + +- редирект неавторизованного пользователя на страницу входа; +- отображение каталога открытых лекций; +- запись на доступную лекцию. + +Тесты работают поверх production preview сборки frontend и используют mock API, поэтому для базового запуска не нужен поднятый backend. + +## Где лежат файлы + +- [playwright.config.ts](../frontend/playwright.config.ts) - конфигурация Playwright. +- [auth.spec.ts](../frontend/tests/e2e/auth.spec.ts) - сценарии аутентификации. +- [catalog.spec.ts](../frontend/tests/e2e/catalog.spec.ts) - сценарии каталога лекций. +- [mockApi.ts](../frontend/tests/e2e/support/mockApi.ts) - перехват и mock ответов API. +- [fixtures.ts](../frontend/tests/mocks/fixtures.ts) - тестовые данные. + +## Как устроен запуск + +Конфигурация находится во `frontend/playwright.config.ts`. + +Основные параметры: + +- `testDir: ./tests/e2e` - Playwright ищет тесты в папке `frontend/tests/e2e`. +- `baseURL: http://127.0.0.1:4173` - базовый адрес приложения в тестах. +- `webServer` запускает `pnpm preview --host 127.0.0.1 --port 4173`. +- В CI включены `2` retry и GitHub reporter. +- Локально используется list reporter. +- По умолчанию проект запускается в Chromium. + +## Команды + +Запуск всех E2E-тестов из корня репозитория: + +```bash +pnpm -C frontend test:e2e +``` + +Интерактивный UI Playwright: + +```bash +pnpm -C frontend test:e2e:ui +``` + +Если нужно вручную поднять preview-сервер: + +```bash +pnpm -C frontend build-only +pnpm -C frontend test:e2e:preview +``` + +После этого можно запускать Playwright с переменной `PW_SKIP_WEB_SERVER=1`, чтобы он не стартовал свой `webServer`. + +## Mock API + +Тесты не обращаются к реальному backend. Вместо этого helper `mockApi(page, options)` перехватывает запросы к `/api/v1` через `page.route`. + +Сейчас замоканы: + +- `POST/GET /api/v1/auth/refresh` - refresh авторизации; +- `/api/v1/auth/me` - текущий пользователь; +- `/api/v1/users/me/stats` - статистика студента; +- `/api/v1/lectures` - список лекций; +- `/api/v1/lectures/{id}/enroll` - запись на лекцию. + +Для авторизованного сценария используйте: + +```ts +await mockApi(page, { authenticated: true }) +``` + +Для проверки гостевого сценария: + +```ts +await mockApi(page, { authenticated: false }) +``` + +## Как добавлять новые тесты + +1. Создавайте spec-файлы в `frontend/tests/e2e`. +2. Для страниц, которым нужен backend, сначала добавляйте нужные ответы в `mockApi.ts` и данные в `fixtures.ts`. +3. Проверяйте пользовательский результат через role/text/label locators: `getByRole`, `getByText`, `getByLabel`. +4. Не завязывайтесь на CSS-классы, если сценарий можно проверить через доступные пользователю элементы. +5. Для маршрутов под авторизацией вызывайте `mockApi(page, { authenticated: true })` до `page.goto(...)`. + +## CI + +Workflow находится в [.gitea/workflows/frontend-playwright.yml](../.gitea/workflows/frontend-playwright.yml). + +Пайплайн: + +1. Устанавливает зависимости через `pnpm install --frozen-lockfile`. +2. Собирает frontend командой `pnpm build-only`. +3. Устанавливает браузер Playwright Chromium. +4. Запускает `pnpm test:e2e`. + +## Артефакты и отладка + +Playwright сохраняет trace на первом retry: `trace: on-first-retry`. + +Локально полезные команды: + +```bash +pnpm -C frontend exec playwright show-report +pnpm -C frontend exec playwright show-trace ./test-results/<папка>/trace.zip +``` + +Папки `frontend/test-results` и `frontend/playwright-report` считаются временными артефактами тестовых прогонов. diff --git a/frontend/.gitignore b/frontend/.gitignore index cd68f14..7cda3d2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -35,5 +35,10 @@ coverage # Vitest __screenshots__/ +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ + # Vite *.timestamp-*-*.mjs diff --git a/frontend/package.json b/frontend/package.json index 759a3bb..8fb4af3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,11 @@ "lint": "run-s lint:*", "lint:oxlint": "oxlint . --fix", "lint:eslint": "eslint . --fix --cache", - "format": "prettier --write --experimental-cli src/" + "format": "prettier --write --experimental-cli src/", + "test:e2e:preview": "vite preview --host 127.0.0.1 --port 4173", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:ui:edge": "PW_USE_SYSTEM_EDGE=1 PW_SKIP_WEB_SERVER=1 playwright test --ui --timeout=0 --workers=1" }, "dependencies": { "pinia": "^3.0.4", @@ -38,7 +42,8 @@ "typescript": "~6.0.0", "vite": "^8.0.8", "vite-plugin-vue-devtools": "^8.1.1", - "vue-tsc": "^3.2.6" + "vue-tsc": "^3.2.6", + "@playwright/test": "^1.54.2" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..159eeb5 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test' + +const useSystemEdge = process.env.PW_USE_SYSTEM_EDGE === '1' +const skipWebServer = process.env.PW_SKIP_WEB_SERVER === '1' + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + webServer: skipWebServer + ? undefined + : { + command: 'pnpm preview --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + cwd: '.', + }, + projects: [ + { + name: useSystemEdge ? 'msedge' : 'chromium', + use: { + ...devices['Desktop Chrome'], + ...(useSystemEdge ? { channel: 'msedge' } : {}), + }, + }, + ], +}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e9d1756..51e8a94 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: specifier: ^5.0.6 version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) devDependencies: + '@playwright/test': + specifier: ^1.54.2 + version: 1.54.2 '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 @@ -433,6 +436,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.54.2': + resolution: {integrity: sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1008,6 +1016,11 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1347,6 +1360,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.54.2: + resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.54.2: + resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==} + engines: {node: '>=18'} + hasBin: true + postcss-safe-parser@7.0.1: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} @@ -2011,6 +2034,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.60.0': optional: true + '@playwright/test@1.54.2': + dependencies: + playwright: 1.54.2 + '@polka/url@1.0.0-next.29': {} '@rolldown/binding-android-arm64@1.0.0-rc.17': @@ -2606,6 +2633,9 @@ snapshots: flatted@3.4.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2889,6 +2919,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.54.2: {} + + playwright@1.54.2: + dependencies: + playwright-core: 1.54.2 + optionalDependencies: + fsevents: 2.3.2 + postcss-safe-parser@7.0.1(postcss@8.5.14): dependencies: postcss: 8.5.14 diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..88469a3 --- /dev/null +++ b/frontend/tests/e2e/auth.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test' +import { mockApi } from './support/mockApi' + +test('redirects unauthenticated user to login', async ({ page }) => { + await mockApi(page, { authenticated: false }) + await page.goto('/catalog') + await expect(page).toHaveURL(/\/login/) + await expect(page.getByText('Войти через ЮФУ')).toBeVisible() +}) diff --git a/frontend/tests/e2e/catalog.spec.ts b/frontend/tests/e2e/catalog.spec.ts new file mode 100644 index 0000000..abc7d92 --- /dev/null +++ b/frontend/tests/e2e/catalog.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test' +import { mockApi } from './support/mockApi' + +test.beforeEach(async ({ page }) => { + await mockApi(page, { authenticated: true }) +}) + +test('renders catalog items from mock api', async ({ page }) => { + await page.goto('/catalog') + + await expect(page.getByRole('heading', { name: 'Каталог открытых лекций' })).toBeVisible() + await expect(page.getByText('Введение в ML')).toBeVisible() + await expect(page.getByText('Квантовые вычисления')).toBeVisible() +}) + +test('register button works for available lecture', async ({ page }) => { + await page.goto('/catalog') + + const firstRegisterButton = page.getByRole('button', { name: 'Записаться' }).first() + await firstRegisterButton.click() + + await expect(page.getByText('Вы записаны на лекцию.')).toBeVisible() +}) diff --git a/frontend/tests/e2e/support/mockApi.ts b/frontend/tests/e2e/support/mockApi.ts new file mode 100644 index 0000000..e9103cd --- /dev/null +++ b/frontend/tests/e2e/support/mockApi.ts @@ -0,0 +1,74 @@ +import { Page } from '@playwright/test' +import { mockAuthResponse, mockCurrentUser, mockLectures, mockUserStats } from '../../mocks/fixtures' + +export async function mockApi(page: Page, options?: { authenticated?: boolean }) { + const authenticated = options?.authenticated ?? false + + await page.route('**/api/v1/auth/refresh', async (route) => { + if (!authenticated) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Unauthorized' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockAuthResponse), + }) + }) + + await page.route('**/api/v1/auth/me', async (route) => { + if (!authenticated) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Unauthorized' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockCurrentUser), + }) + }) + + await page.route('**/api/v1/users/me/stats', async (route) => { + if (!authenticated) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Unauthorized' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockUserStats), + }) + }) + + await page.route('**/api/v1/lectures/*/enroll', async (route) => { + await route.fulfill({ status: 204 }) + }) + + await page.route(/\/api\/v1\/lectures(\?.*)?$/, async (route) => { + if (route.request().method() !== 'GET') { + await route.continue() + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: mockLectures }), + }) + }) +} diff --git a/frontend/tests/mocks/fixtures.ts b/frontend/tests/mocks/fixtures.ts new file mode 100644 index 0000000..1ad0547 --- /dev/null +++ b/frontend/tests/mocks/fixtures.ts @@ -0,0 +1,80 @@ +export const mockAuthResponse = { + accessToken: 'fake-token', + expiresAt: '2099-01-01T00:00:00.000Z', + user: { + id: 1, + email: 'student@example.com', + displayName: 'Test User', + roles: ['Student'], + }, +} + +export const mockCurrentUser = { + id: 1, + email: 'student@example.com', + displayName: 'Test User', + roles: ['Student'], + avatarUrl: null, + xp: 120, + coins: 10, + level: 2, + createdAt: '2026-01-01T00:00:00.000Z', +} + +export const mockUserStats = { + totalLectures: 0, + attendedLectures: 0, + totalReviews: 0, + xp: 120, + coins: 10, + level: 2, + achievementsCount: 0, + currentLevelXp: 20, + nextLevelXp: 100, + activeEnrollments: 0, + enrollmentSlotLimit: 3, + enrollmentSlotRules: [], +} + +export const mockLectures = [ + { + id: 1, + courseId: 101, + courseName: 'ML', + teacherId: 201, + teacherName: 'Иванов И.И.', + locationId: 301, + locationName: 'B-1 / 101', + title: 'Введение в ML', + description: 'База машинного обучения', + format: 'Offline', + startsAt: '2026-06-12T10:00:00.000Z', + endsAt: '2026-06-12T11:30:00.000Z', + isOpen: true, + maxEnrollments: 30, + enrollmentsCount: 10, + onlineUrl: null, + createdAt: '2026-01-01T00:00:00.000Z', + isEnrolled: false, + }, + { + id: 2, + courseId: 102, + courseName: 'квантовые-вычисления', + teacherId: 202, + teacherName: 'Петров П.П.', + locationId: null, + locationName: null, + title: 'Квантовые вычисления', + description: 'Кубиты и алгоритмы', + format: 'Online', + startsAt: '2026-06-13T10:00:00.000Z', + endsAt: '2026-06-13T11:00:00.000Z', + isOpen: false, + maxEnrollments: 50, + enrollmentsCount: 50, + onlineUrl: 'https://example.com/meet', + createdAt: '2026-01-01T00:00:00.000Z', + isEnrolled: false, + }, +] From cce7bea12ff4c67b381841da8974f7938151b90a Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 19:28:21 +0300 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B2=20DataTa?= =?UTF-8?q?ble.vue=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20Playwright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 34 ++++++++++++------------ frontend/src/components/ui/DataTable.vue | 10 +++---- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 8fb4af3..491f93a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,7 +43,7 @@ "vite": "^8.0.8", "vite-plugin-vue-devtools": "^8.1.1", "vue-tsc": "^3.2.6", - "@playwright/test": "^1.54.2" + "@playwright/test": "^1.55.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 51e8a94..2102929 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) devDependencies: '@playwright/test': - specifier: ^1.54.2 - version: 1.54.2 + specifier: ^1.55.1 + version: 1.60.0 '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 @@ -436,8 +436,8 @@ packages: cpu: [x64] os: [win32] - '@playwright/test@1.54.2': - resolution: {integrity: sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -787,8 +787,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -1360,13 +1360,13 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - playwright-core@1.54.2: - resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.54.2: - resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -2034,9 +2034,9 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.60.0': optional: true - '@playwright/test@1.54.2': + '@playwright/test@1.60.0': dependencies: - playwright: 1.54.2 + playwright: 1.60.0 '@polka/url@1.0.0-next.29': {} @@ -2414,7 +2414,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2792,7 +2792,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 mitt@3.0.1: {} @@ -2919,11 +2919,11 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - playwright-core@1.54.2: {} + playwright-core@1.60.0: {} - playwright@1.54.2: + playwright@1.60.0: dependencies: - playwright-core: 1.54.2 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 diff --git a/frontend/src/components/ui/DataTable.vue b/frontend/src/components/ui/DataTable.vue index 0bb1a2a..152d395 100644 --- a/frontend/src/components/ui/DataTable.vue +++ b/frontend/src/components/ui/DataTable.vue @@ -1,15 +1,15 @@ From ef2fd39508ae98c7e05d3c3c4370cb9f06c00405 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 05:04:43 +0300 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=8E=D0=BD?= =?UTF-8?q?=D0=B8=D1=82-=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/MappingExtensionsTests.cs | 123 +++++++++++++++ .../Courses/CourseServiceTests.cs | 121 ++++++++++++++ .../Domain/EnrollmentSlotPolicyTests.cs | 29 ++++ .../Tags/TagServiceTests.cs | 86 ++++++++++ .../Users/UserServiceTests.cs | 149 ++++++++++++++++++ docs/backend-unit-tests.md | 143 +++++++++++++++++ 6 files changed, 651 insertions(+) create mode 100644 backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs create mode 100644 backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs create mode 100644 backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs create mode 100644 backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs create mode 100644 docs/backend-unit-tests.md diff --git a/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs new file mode 100644 index 0000000..138b38c --- /dev/null +++ b/backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs @@ -0,0 +1,123 @@ +using UniVerse.Application.Mappings; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using Xunit; + +namespace UniVerse.Api.Tests.Application; + +public class MappingExtensionsTests +{ + [Fact] + public void UserMappings_OrderRolesConsistently() + { + var user = new User + { + Id = 1, + Email = "user@test.local", + DisplayName = "User", + AvatarUrl = "avatar.png", + IsActive = true, + Xp = 120, + Coins = 30, + CreatedAt = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Roles = + [ + new UserRoleAssignment { Role = UserRole.Teacher }, + new UserRoleAssignment { Role = UserRole.Student }, + new UserRoleAssignment { Role = UserRole.Admin } + ] + }; + + var dto = user.ToDto(level: 2); + var currentUser = user.ToCurrentUserDto(level: 2); + var auth = user.ToAuthDto(); + + Assert.Equal(new[] { UserRole.Student, UserRole.Teacher, UserRole.Admin }, dto.Roles); + Assert.Equal(dto.Roles, currentUser.Roles); + Assert.Equal(dto.Roles, auth.Roles); + Assert.Equal(2, dto.Level); + } + + [Fact] + public void LectureMappings_UseNavigationFallbacksAndEnrollmentCount() + { + var startsAt = new DateTime(2026, 2, 1, 9, 0, 0, DateTimeKind.Utc); + var lecture = new Lecture + { + Id = 10, + CourseId = 5, + Title = "Offline lecture", + Description = "Description", + Format = LectureFormat.Offline, + StartsAt = startsAt, + EndsAt = startsAt.AddHours(2), + IsOpen = true, + MaxEnrollments = 25, + Enrollments = + [ + new LectureEnrollment { UserId = 1 }, + new LectureEnrollment { UserId = 2 } + ] + }; + + var dto = lecture.ToDto(isEnrolled: true); + var detail = lecture.ToDetailDto(isEnrolled: false); + + Assert.Equal("", dto.CourseName); + Assert.Null(dto.TeacherName); + Assert.Null(dto.LocationName); + Assert.Equal(2, dto.EnrollmentsCount); + Assert.True(dto.IsEnrolled); + Assert.False(detail.IsEnrolled); + } + + [Fact] + public void ReviewMapping_CopiesAnalysisFields() + { + var review = new Review + { + Id = 7, + LectureId = 3, + UserId = 4, + Rating = ReviewRating.Like, + Text = "Clear and useful", + LlmStatus = ReviewLlmStatus.Analyzed, + Sentiment = ReviewSentiment.Positive, + QualityScore = 0.95, + IsInformative = true, + LlmTags = ["clear", "useful"], + LlmRawOutput = "{\"quality_score\":0.95}", + CreatedAt = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc), + Lecture = new Lecture { Title = "Lecture title" }, + User = new User { DisplayName = "Student" } + }; + + var dto = review.ToDto(); + + Assert.Equal("Lecture title", dto.LectureTitle); + Assert.Equal("Student", dto.UserName); + Assert.Equal(ReviewSentiment.Positive, dto.Sentiment); + Assert.Equal(0.95, dto.QualityScore); + Assert.True(dto.IsInformative); + Assert.NotNull(dto.LlmTags); + Assert.Equal(["clear", "useful"], dto.LlmTags); + Assert.Equal("{\"quality_score\":0.95}", dto.LlmRawOutput); + } + + [Fact] + public void TagTreeMapping_MapsChildrenRecursively() + { + var root = new Tag { Id = 1, Name = "Root", Type = TagType.Topic }; + var child = new Tag { Id = 2, Name = "Child", Type = TagType.Subject, ParentId = 1 }; + var grandchild = new Tag { Id = 3, Name = "Grandchild", Type = TagType.Other, ParentId = 2 }; + root.Children.Add(child); + child.Children.Add(grandchild); + + var dto = root.ToTreeDto(); + + Assert.Equal("Root", dto.Name); + var childDto = Assert.Single(dto.Children); + Assert.Equal("Child", childDto.Name); + Assert.Equal("Grandchild", Assert.Single(childDto.Children).Name); + } +} diff --git a/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs b/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs new file mode 100644 index 0000000..cc1c50e --- /dev/null +++ b/backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Courses; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Courses; + +public class CourseServiceTests +{ + [Fact] + public async Task GetAllAsync_AppliesSearchSyncedTagFiltersAndPagination() + { + await using var db = CreateDbContext(); + db.Tags.AddRange( + new Tag { Id = 1, Name = "Backend", Type = TagType.Subject }, + new Tag { Id = 2, Name = "Frontend", Type = TagType.Subject }); + db.Courses.AddRange( + Course(1, "ASP.NET Core", true, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc)), + Course(2, "Vue Basics", true, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)), + Course(3, "Advanced ASP.NET", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + db.CourseTags.AddRange( + new CourseTag { CourseId = 1, TagId = 1 }, + new CourseTag { CourseId = 2, TagId = 2 }, + new CourseTag { CourseId = 3, TagId = 1 }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + var result = await service.GetAllAsync(new CourseFilterRequest( + TagId: 1, + Search: "asp", + IsSynced: true, + Page: 1, + PageSize: 10)); + + var item = Assert.Single(result.Items); + Assert.Equal(1, item.Id); + Assert.Equal(1, result.TotalCount); + Assert.Equal(1, result.TotalPages); + Assert.Equal("Backend", Assert.Single(item.Tags).Name); + } + + [Fact] + public async Task GetAllAsync_ReturnsRequestedPageMetadata() + { + await using var db = CreateDbContext(); + db.Courses.AddRange( + Course(1, "Old", false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)), + Course(2, "Middle", false, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)), + Course(3, "Newest", false, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc))); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + var result = await service.GetAllAsync(new CourseFilterRequest(null, null, null, Page: 2, PageSize: 1)); + + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.Page); + Assert.Equal(1, result.PageSize); + Assert.Equal(3, result.TotalPages); + Assert.Equal(2, Assert.Single(result.Items).Id); + } + + [Fact] + public async Task AddTagAsync_LinksExistingCourseAndTag() + { + await using var db = CreateDbContext(); + db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow)); + db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + await service.AddTagAsync(1, 10); + + Assert.True(await db.CourseTags.AnyAsync(ct => ct.CourseId == 1 && ct.TagId == 10)); + } + + [Fact] + public async Task AddTagAsync_ThrowsWhenTagAlreadyLinked() + { + await using var db = CreateDbContext(); + db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow)); + db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic }); + db.CourseTags.Add(new CourseTag { CourseId = 1, TagId = 10 }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + await Assert.ThrowsAsync(() => service.AddTagAsync(1, 10)); + } + + [Fact] + public async Task AddTagAsync_ThrowsWhenCourseOrTagMissing() + { + await using var db = CreateDbContext(); + db.Courses.Add(Course(1, "Course", false, DateTime.UtcNow)); + db.Tags.Add(new Tag { Id = 10, Name = "Tag", Type = TagType.Topic }); + await db.SaveChangesAsync(); + var service = new CourseService(db); + + await Assert.ThrowsAsync(() => service.AddTagAsync(404, 10)); + await Assert.ThrowsAsync(() => service.AddTagAsync(1, 404)); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"CourseServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private static Course Course(int id, string name, bool isSynced, DateTime createdAt) => new() + { + Id = id, + Name = name, + IsSynced = isSynced, + CreatedAt = createdAt + }; +} diff --git a/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs b/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs new file mode 100644 index 0000000..741a447 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs @@ -0,0 +1,29 @@ +using UniVerse.Domain.Services; +using Xunit; + +namespace UniVerse.Api.Tests.DomainServices; + +public class EnrollmentSlotPolicyTests +{ + [Theory] + [InlineData(-1, 3)] + [InlineData(0, 3)] + [InlineData(1, 3)] + [InlineData(2, 3)] + [InlineData(3, 5)] + [InlineData(4, 7)] + [InlineData(10, 7)] + public void GetLimitForLevel_UsesHighestMatchingRuleOrDefault(int level, int expectedSlots) + { + var slots = EnrollmentSlotPolicy.GetLimitForLevel(level); + + Assert.Equal(expectedSlots, slots); + } + + [Fact] + public void Rules_ExposeConfiguredThresholdsInAscendingOrder() + { + Assert.Equal(new[] { 1, 3, 4 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Level)); + Assert.Equal(new[] { 3, 5, 7 }, EnrollmentSlotPolicy.Rules.Select(rule => rule.Slots)); + } +} diff --git a/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs b/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs new file mode 100644 index 0000000..fa9c6bd --- /dev/null +++ b/backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Tags; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Tags; + +public class TagServiceTests +{ + [Fact] + public async Task GetAllAsync_FiltersByTypeAndParentAndOrdersByName() + { + await using var db = CreateDbContext(); + db.Tags.AddRange( + new Tag { Id = 1, Name = "Root", Type = TagType.Topic }, + new Tag { Id = 2, Name = "Zeta", Type = TagType.Subject, ParentId = 1 }, + new Tag { Id = 3, Name = "Alpha", Type = TagType.Subject, ParentId = 1 }, + new Tag { Id = 4, Name = "Other parent", Type = TagType.Subject, ParentId = 99 }, + new Tag { Id = 5, Name = "Other type", Type = TagType.Topic, ParentId = 1 }); + await db.SaveChangesAsync(); + var service = new TagService(db); + + var result = await service.GetAllAsync(TagType.Subject, parentId: 1); + + Assert.Equal(new[] { "Alpha", "Zeta" }, result.Select(tag => tag.Name)); + } + + [Fact] + public async Task CreateAsync_ThrowsWhenParentMissing() + { + await using var db = CreateDbContext(); + var service = new TagService(db); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 404))); + } + + [Fact] + public async Task CreateAsync_CreatesChildWhenParentExists() + { + await using var db = CreateDbContext(); + db.Tags.Add(new Tag { Id = 1, Name = "Parent", Type = TagType.Topic }); + await db.SaveChangesAsync(); + var service = new TagService(db); + + var created = await service.CreateAsync(new CreateTagRequest("Child", TagType.Subject, ParentId: 1)); + + Assert.Equal("Child", created.Name); + Assert.Equal(1, created.ParentId); + Assert.True(await db.Tags.AnyAsync(tag => tag.Name == "Child" && tag.ParentId == 1)); + } + + [Fact] + public async Task GetTreeAsync_ReturnsNestedRootTrees() + { + await using var db = CreateDbContext(); + db.Tags.AddRange( + new Tag { Id = 1, Name = "Root A", Type = TagType.Topic }, + new Tag { Id = 2, Name = "Child A", Type = TagType.Subject, ParentId = 1 }, + new Tag { Id = 3, Name = "Grandchild A", Type = TagType.Other, ParentId = 2 }, + new Tag { Id = 4, Name = "Root B", Type = TagType.Organization }); + await db.SaveChangesAsync(); + var service = new TagService(db); + + var tree = await service.GetTreeAsync(); + + Assert.Equal(new[] { "Root A", "Root B" }, tree.Select(tag => tag.Name)); + var rootA = tree.Single(tag => tag.Name == "Root A"); + var child = Assert.Single(rootA.Children); + Assert.Equal("Child A", child.Name); + Assert.Equal("Grandchild A", Assert.Single(child.Children).Name); + Assert.Empty(tree.Single(tag => tag.Name == "Root B").Children); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs index 7e13394..0559834 100644 --- a/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Users/UserServiceTests.cs @@ -2,8 +2,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using UniVerse.Application.DTOs.Notifications; +using UniVerse.Application.DTOs.Users; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Domain.Exceptions; using UniVerse.Infrastructure.Data; using UniVerse.Infrastructure.Services; using Xunit; @@ -71,6 +74,125 @@ public class UserServiceTests Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots)); } + [Fact] + public async Task SetRolesAsync_DeduplicatesRolesAndCreatesProfiles() + { + await using var db = CreateDbContext(); + db.Users.Add(new User + { + Id = 1, + Email = "user@test.local", + Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }] + }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + await service.SetRolesAsync(1, [UserRole.Teacher, UserRole.Teacher, UserRole.Student]); + + var user = await db.Users + .Include(u => u.Roles) + .FirstAsync(u => u.Id == 1); + Assert.Equal(new[] { UserRole.Student, UserRole.Teacher }, user.Roles.Select(role => role.Role).OrderBy(role => role)); + Assert.Equal(2, user.Roles.Count); + Assert.True(await db.StudentProfiles.AnyAsync(profile => profile.UserId == 1)); + Assert.True(await db.TeacherProfiles.AnyAsync(profile => profile.UserId == 1)); + } + + [Fact] + public async Task SetRolesAsync_RejectsEmptyRoleSet() + { + await using var db = CreateDbContext(); + db.Users.Add(new User + { + Id = 1, + Email = "user@test.local", + Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }] + }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + await Assert.ThrowsAsync(() => service.SetRolesAsync(1, [])); + } + + [Fact] + public async Task SetRolesAsync_PreservesExistingProfiles() + { + await using var db = CreateDbContext(); + db.Users.Add(new User + { + Id = 1, + Email = "user@test.local", + Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }] + }); + db.StudentProfiles.Add(new StudentProfile + { + Id = 10, + UserId = 1, + StudentId = "S-1" + }); + db.TeacherProfiles.Add(new TeacherProfile + { + Id = 20, + UserId = 1, + Department = "Math" + }); + await db.SaveChangesAsync(); + var service = CreateService(db); + + await service.SetRolesAsync(1, [UserRole.Teacher]); + + Assert.Equal(1, await db.StudentProfiles.CountAsync(profile => profile.UserId == 1)); + Assert.Equal(1, await db.TeacherProfiles.CountAsync(profile => profile.UserId == 1)); + Assert.Equal("S-1", (await db.StudentProfiles.SingleAsync(profile => profile.UserId == 1)).StudentId); + Assert.Equal("Math", (await db.TeacherProfiles.SingleAsync(profile => profile.UserId == 1)).Department); + } + + [Fact] + public async Task GetAllAsync_FiltersBySearchActiveAndExactSingleRole() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.AddRange( + User(1, "anna@test.local", "Anna", true, 120, UserRole.Student), + User(2, "anna.teacher@test.local", "Anna Teacher", true, 120, UserRole.Teacher), + User(3, "anna.admin@test.local", "Anna Admin", true, 120, UserRole.Student, UserRole.Admin), + User(4, "inactive@test.local", "Anna Inactive", false, 120, UserRole.Student)); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var result = await service.GetAllAsync(new UserFilterRequest( + Search: "anna", + Role: UserRole.Student, + IsActive: true, + Page: 1, + PageSize: 10)); + + var user = Assert.Single(result.Items); + Assert.Equal(1, user.Id); + Assert.Equal(2, user.Level); + Assert.Equal(1, result.TotalCount); + } + + [Fact] + public async Task GetAllAsync_ReturnsRequestedPageInCreatedAtDescendingOrder() + { + await using var db = CreateDbContext(); + SeedLevelThresholds(db); + db.Users.AddRange( + User(1, "old@test.local", "Old", true, 0, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), UserRole.Student), + User(2, "middle@test.local", "Middle", true, 100, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), UserRole.Student), + User(3, "new@test.local", "New", true, 300, new DateTime(2026, 1, 3, 0, 0, 0, DateTimeKind.Utc), UserRole.Student)); + await db.SaveChangesAsync(); + var service = CreateService(db); + + var result = await service.GetAllAsync(new UserFilterRequest(null, null, null, Page: 2, PageSize: 1)); + + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.Page); + Assert.Equal(3, result.TotalPages); + Assert.Equal(2, Assert.Single(result.Items).Id); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -115,4 +237,31 @@ public class UserServiceTests IsOpen = true, MaxEnrollments = 30 }; + + private static User User( + int id, + string email, + string displayName, + bool isActive, + int xp, + params UserRole[] roles) => + User(id, email, displayName, isActive, xp, DateTime.UtcNow, roles); + + private static User User( + int id, + string email, + string displayName, + bool isActive, + int xp, + DateTime createdAt, + params UserRole[] roles) => new() + { + Id = id, + Email = email, + DisplayName = displayName, + IsActive = isActive, + Xp = xp, + CreatedAt = createdAt, + Roles = roles.Select(role => new UserRoleAssignment { UserId = id, Role = role }).ToList() + }; } diff --git a/docs/backend-unit-tests.md b/docs/backend-unit-tests.md new file mode 100644 index 0000000..9d91d19 --- /dev/null +++ b/docs/backend-unit-tests.md @@ -0,0 +1,143 @@ +# Backend unit-тесты + +## Назначение + +Unit- и service-тесты backend проверяют бизнес-логику без запуска HTTP API: + +- доменные правила записи на лекции; +- преобразование сущностей в DTO; +- фильтрацию, пагинацию и связи курсов; +- дерево тегов; +- управление ролями и профилями пользователей. + +Security-тесты авторизации находятся в том же тестовом проекте, но это отдельный интеграционный набор: они запускают API через `WebApplicationFactory` и проверяют HTTP-доступ к endpoint-ам. + +## Где лежат файлы + +- [EnrollmentSlotPolicyTests.cs](../backend/UniVerse.Api.Tests/Domain/EnrollmentSlotPolicyTests.cs) - правила лимита активных записей по уровню. +- [MappingExtensionsTests.cs](../backend/UniVerse.Api.Tests/Application/MappingExtensionsTests.cs) - маппинг доменных сущностей в DTO. +- [CourseServiceTests.cs](../backend/UniVerse.Api.Tests/Courses/CourseServiceTests.cs) - фильтры, пагинация и теги курсов. +- [TagServiceTests.cs](../backend/UniVerse.Api.Tests/Tags/TagServiceTests.cs) - фильтры тегов и построение дерева. +- [UserServiceTests.cs](../backend/UniVerse.Api.Tests/Users/UserServiceTests.cs) - статистика, роли, профили и список пользователей. +- [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs) - security-тесты ролевого доступа к API. +- [ApiWebApplicationFactory.cs](../backend/UniVerse.Api.Tests/Helpers/ApiWebApplicationFactory.cs) - тестовый запуск API. +- [TestJwtFactory.cs](../backend/UniVerse.Api.Tests/Helpers/TestJwtFactory.cs) - генерация JWT для ролей в security-тестах. + +Тестовый проект: [UniVerse.Api.Tests.csproj](../backend/UniVerse.Api.Tests/UniVerse.Api.Tests.csproj). + +## Тестовый стек + +- `xUnit` - test runner и assertions. +- `NSubstitute` - mock-объекты для сервисных зависимостей. +- `Microsoft.EntityFrameworkCore.InMemory` - изолированная InMemory БД для service-тестов. + +Каждый service-тест создает отдельный `AppDbContext` с уникальным именем базы через `Guid.NewGuid()`, чтобы данные разных тестов не пересекались. + +## Что покрыто + +### EnrollmentSlotPolicy + +Проверяется, что `GetLimitForLevel` выбирает последний подходящий threshold: + +- уровни ниже первого правила получают базовый лимит; +- уровни между threshold используют предыдущий лимит; +- уровни выше последнего threshold используют максимальный лимит; +- публичный список `Rules` остается в ожидаемом порядке. + +### MappingExtensions + +Проверяется стабильность DTO-маппинга: + +- роли пользователя сортируются одинаково в `UserDto`, `CurrentUserDto` и `UserAuthDto`; +- лекции корректно считают записи и используют fallback для отсутствующих navigation properties; +- отзывы переносят поля LLM-анализа; +- дерево тегов маппится рекурсивно. + +### CourseService + +Проверяется поведение без HTTP-слоя: + +- совместная работа фильтров `Search`, `IsSynced`, `TagId`; +- корректные `TotalCount`, `Page`, `PageSize`, `TotalPages`; +- добавление связи курс-тег; +- ошибки при повторной связи или отсутствующем курсе/теге. + +### TagService + +Проверяется: + +- фильтрация по `TagType` и `ParentId`; +- сортировка по имени; +- запрет создания дочернего тега без существующего родителя; +- построение вложенного дерева тегов. + +### UserService + +Проверяется: + +- статистика пользователя, прогресс уровня и лимиты записей; +- `SetRolesAsync` удаляет дубли ролей; +- пустой набор ролей отклоняется; +- профили студента и преподавателя создаются и не дублируются; +- `GetAllAsync` фильтрует по поиску, активности и одиночной роли; +- пагинация пользователей идет в порядке `CreatedAt` по убыванию. + +## Security-тесты авторизации + +Security-тесты находятся в [EndpointAuthorizationTests.cs](../backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs). Это интеграционные тесты, которые отправляют реальные HTTP-запросы в тестовый API через `ApiWebApplicationFactory`. + +Они проверяют не бизнес-результат endpoint-а, а сам факт прохождения или блокировки авторизации: + +- анонимный запрос к защищенному endpoint-у получает `401 Unauthorized`; +- запрос с неподходящей ролью получает `403 Forbidden`; +- запрос с подходящей ролью не получает `401` или `403`; +- публичные endpoint-ы из `AnonymousEndpoints` доступны без JWT и не возвращают `401` от middleware авторизации. + +Таблица защищенных endpoint-ов задается в методе `AuthenticatedEndpoints`. Каждый кейс описывает: + +- человекочитаемое имя сценария; +- HTTP-метод; +- URL; +- роль, которая должна пройти авторизацию; +- роли, которые должны получить `403`; +- опциональное JSON-тело запроса. + +Для endpoint-ов, доступных любой авторизованной роли, используется обычная тестовая роль, чаще `Student`, и пустой список запрещенных ролей. Для endpoint-ов с несколькими разрешенными ролями добавляется отдельный кейс на каждую разрешенную роль, например `Admin` и `Teacher`. + +JWT для ролей создаются через `TestJwtFactory.BearerHeader(role)`. Это позволяет проверять backend-авторизацию без Microsoft OAuth flow и без реального входа пользователя. + +## Как обновлять security-тесты + +При добавлении или изменении API endpoint-а нужно обновить `EndpointAuthorizationTests`: + +1. Если endpoint требует авторизации, добавьте его в `AuthenticatedEndpoints`. +2. Укажите правильную роль или отдельные кейсы для нескольких ролей. +3. Для role-specific endpoint-а заполните `forbidden` ролями, которые должны получать `403`. +4. Если endpoint публичный, добавьте его в `AnonymousEndpoints`. +5. Для `POST`, `PUT`, `PATCH` endpoint-ов добавьте минимальное валидное тело запроса, чтобы тест дошел до авторизации и не падал на model binding раньше времени. + +Security-тест считается успешным для правильной роли, если ответ не `401` и не `403`. Это намеренно: после авторизации endpoint может вернуть `404`, `400`, `409` или другой доменный ответ из-за тестовых данных, и это не является ошибкой проверки доступа. + +## Как запускать + +Из корня репозитория: + +```bash +dotnet test backend/UniVerse.sln --no-restore +``` + +## Как добавлять новые unit/service-тесты + +1. Размещайте тесты рядом с проверяемой областью внутри `backend/UniVerse.Api.Tests`. +2. Для сервисов с EF используйте InMemory `AppDbContext` с уникальным именем базы. +3. Мокайте только внешние зависимости и соседние сервисы через `NSubstitute`. +4. Не запускайте `WebApplicationFactory`, если проверяется не HTTP/auth behavior. +5. Покрывайте не только успешный сценарий, но и доменные ошибки: `NotFoundException`, `ConflictException`, `ForbiddenException`. + +## Текущий baseline + +После добавления unit/service-тестов и с учетом существующих security-тестов полный backend test suite проходит: + +```text +Passed: 303, Failed: 0, Skipped: 0 +``` From c93d205e3476f425d1893c94e132d2c7d9e44b38 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 19:54:38 +0300 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ui/DataTable.vue | 6 +++--- frontend/src/views/admin/AdminLecturesView.vue | 3 ++- frontend/src/views/admin/AdminReviewsView.vue | 4 +++- frontend/src/views/admin/AdminUsersView.vue | 4 +++- frontend/src/views/student/CatalogView.vue | 4 +++- frontend/src/views/teacher/TeacherLecturesView.vue | 3 ++- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/ui/DataTable.vue b/frontend/src/components/ui/DataTable.vue index 152d395..e75fdb1 100644 --- a/frontend/src/components/ui/DataTable.vue +++ b/frontend/src/components/ui/DataTable.vue @@ -1,15 +1,15 @@ diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue index 17f4616..92c4231 100644 --- a/frontend/src/views/admin/AdminLecturesView.vue +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -17,9 +17,10 @@ import EmptyState from '@/components/ui/EmptyState.vue' import CreateLectureModal from '@/components/admin/CreateLectureModal.vue' type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags' +type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } type TabConfig = { title: string - columns: Array<{ key: string; label: string; align?: string }> + columns: DataTableColumn[] rows: Record[] } diff --git a/frontend/src/views/admin/AdminReviewsView.vue b/frontend/src/views/admin/AdminReviewsView.vue index aef782a..132521e 100644 --- a/frontend/src/views/admin/AdminReviewsView.vue +++ b/frontend/src/views/admin/AdminReviewsView.vue @@ -7,7 +7,9 @@ import EmptyState from '@/components/ui/EmptyState.vue' import { reviewsApi } from '@/api' import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types' -const columns = [ +type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } + +const columns: DataTableColumn[] = [ { key: 'id', label: 'ID' }, { key: 'lecture', label: 'Лекция' }, { key: 'student', label: 'Студент' }, diff --git a/frontend/src/views/admin/AdminUsersView.vue b/frontend/src/views/admin/AdminUsersView.vue index e0580fd..78e4890 100644 --- a/frontend/src/views/admin/AdminUsersView.vue +++ b/frontend/src/views/admin/AdminUsersView.vue @@ -13,7 +13,9 @@ const users = ref([]) const loading = ref(false) const error = ref('') -const columns = [ +type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } + +const columns: DataTableColumn[] = [ { key: 'name', label: 'Имя' }, { key: 'email', label: 'Email' }, { key: 'role', label: 'Роль', align: 'center' }, diff --git a/frontend/src/views/student/CatalogView.vue b/frontend/src/views/student/CatalogView.vue index e309f17..b2ed462 100644 --- a/frontend/src/views/student/CatalogView.vue +++ b/frontend/src/views/student/CatalogView.vue @@ -11,6 +11,8 @@ import ModalDialog from '@/components/ui/ModalDialog.vue' import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue' const lecturesStore = useLecturesStore() +type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } + const search = ref('') const viewMode = ref<'cards' | 'list' | 'calendar'>('cards') const dateFilter = ref('Любая дата') @@ -102,7 +104,7 @@ const appliedFilters = computed(() => { return filters }) -const tableColumns = [ +const tableColumns: DataTableColumn[] = [ { key: 'title', label: 'Лекция' }, { key: 'teacher', label: 'Преподаватель' }, { key: 'date', label: 'Дата' }, diff --git a/frontend/src/views/teacher/TeacherLecturesView.vue b/frontend/src/views/teacher/TeacherLecturesView.vue index 15e3bb0..5a0c89c 100644 --- a/frontend/src/views/teacher/TeacherLecturesView.vue +++ b/frontend/src/views/teacher/TeacherLecturesView.vue @@ -8,8 +8,9 @@ import StatusBadge from '@/components/ui/StatusBadge.vue' import EmptyState from '@/components/ui/EmptyState.vue' const auth = useAuthStore() const lecturesStore = useLecturesStore() +type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' } -const columns = [ +const columns: DataTableColumn[] = [ { key: 'title', label: 'Лекция' }, { key: 'date', label: 'Дата' }, { key: 'status', label: 'Статус', align: 'center' }, From 7f923cd612a3b25a3c800fb0b98f96c4ac8495cf Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 28 May 2026 20:00:05 +0300 Subject: [PATCH 13/13] =?UTF-8?q?feat:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B2=20DataTable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ui/DataTable.vue | 11 +++++------ frontend/src/views/admin/AdminLecturesView.vue | 2 +- frontend/src/views/admin/AdminReviewsView.vue | 4 ++-- frontend/src/views/teacher/TeacherLecturesView.vue | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ui/DataTable.vue b/frontend/src/components/ui/DataTable.vue index e75fdb1..d64c64a 100644 --- a/frontend/src/components/ui/DataTable.vue +++ b/frontend/src/components/ui/DataTable.vue @@ -1,16 +1,15 @@ - diff --git a/frontend/src/views/admin/AdminLecturesView.vue b/frontend/src/views/admin/AdminLecturesView.vue index 92c4231..38418aa 100644 --- a/frontend/src/views/admin/AdminLecturesView.vue +++ b/frontend/src/views/admin/AdminLecturesView.vue @@ -162,7 +162,7 @@ const tabConfig: Record = { }, } -const current = computed(() => { +const current = computed(() => { const config = tabConfig[activeTab.value] if (activeTab.value === 'lectures') { return { diff --git a/frontend/src/views/admin/AdminReviewsView.vue b/frontend/src/views/admin/AdminReviewsView.vue index 132521e..b436b58 100644 --- a/frontend/src/views/admin/AdminReviewsView.vue +++ b/frontend/src/views/admin/AdminReviewsView.vue @@ -297,8 +297,8 @@ onMounted(() => { -