Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8223697bd3 | |||
| 3106f0ef61 | |||
| a220afd078 | |||
| cd3f2c53b7 |
@@ -31,7 +31,9 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '22.x'
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,209 +1,59 @@
|
|||||||
# UniVerse
|
# UniVerse
|
||||||
|
|
||||||
UniVerse - веб-платформа для открытых межнаправленческих лекций университета. Система помогает студентам находить занятия других направлений, записываться на них, получать напоминания, оставлять отзывы, а преподавателям и администраторам - видеть аналитику посещаемости и качества обратной связи.
|
UniVerse — backend (ASP.NET Core) для университетской платформы расписания, лекций, отзывов и геймификации.
|
||||||
|
|
||||||
Проект состоит из Vue 3 frontend, ASP.NET Core backend и PostgreSQL-хранилища. Backend предоставляет REST API, интегрируется с Microsoft Entra ID, Modeus API и OpenAI-compatible LLM, а frontend реализует отдельные сценарии для ролей `Student`, `Teacher` и `Admin`.
|
[Документация API](backend/UniVerse.Api/openapi.json)
|
||||||
|
[Документация бекнда](docs/backend.md)
|
||||||
|
|
||||||
- [OpenAPI snapshot](backend/UniVerse.Api/openapi.json)
|
## Что внутри
|
||||||
- [Backend notes](docs/backend.md)
|
|
||||||
- [Frontend E2E tests](docs/playwright-tests.md)
|
|
||||||
- [Load testing](docs/load-testing-k6.md)
|
|
||||||
|
|
||||||
## Возможности
|
- Расписание/события и сущности: курсы, лекции, аудитории (locations)
|
||||||
|
- Отзывы студентов с фоновым LLM-анализом (качество/тональность/теги)
|
||||||
|
- Геймификация: XP/уровни, монеты, достижения
|
||||||
|
- JWT-аутентификация и роли (`Admin`, `Teacher`, `Student`)
|
||||||
|
- Swagger/OpenAPI в Development
|
||||||
|
|
||||||
### Для студента
|
## Технологии
|
||||||
|
|
||||||
- Каталог открытых лекций с поиском, фильтрацией, карточками и деталями занятия.
|
- .NET 10 / ASP.NET Core
|
||||||
- Запись на лекцию и отмена записи с учетом лимитов мест и персонального лимита активных записей.
|
- PostgreSQL + EF Core (Npgsql)
|
||||||
- Личный дашборд: ближайшие лекции, прогресс уровня, XP, монеты, достижения и статистика.
|
- Serilog
|
||||||
- Мои лекции: список записей, скачивание `.ics` для одной лекции или всего расписания, ссылка календарной подписки.
|
- Swagger (Swashbuckle)
|
||||||
- Отзывы о лекциях с оценкой `Like`, `Neutral`, `Dislike`.
|
|
||||||
- Уведомления и профиль пользователя.
|
|
||||||
|
|
||||||
### Для преподавателя
|
## Структура репозитория
|
||||||
|
|
||||||
- Дашборд преподавателя по своим занятиям.
|
Код backend лежит в папке `backend/` и собран в solution `backend/UniVerse.sln`:
|
||||||
- Просмотр списка лекций и записей.
|
|
||||||
- Аналитика отзывов: тональность, информативность, теги LLM и агрегированные показатели.
|
|
||||||
- Работа с отзывами без раскрытия лишних персональных данных студентам.
|
|
||||||
|
|
||||||
### Для администратора
|
- `backend/UniVerse.Api` — Web API (контроллеры, middleware, background services)
|
||||||
|
- `backend/UniVerse.Application` — DTO, интерфейсы сервисов, маппинги
|
||||||
- Административная панель со статистикой пользователей, лекций, записей и ожидающих LLM-анализа отзывов.
|
- `backend/UniVerse.Domain` — доменные сущности/enum/исключения
|
||||||
- Управление пользователями: роли `Student`, `Teacher`, `Admin`, блокировка и разблокировка аккаунтов.
|
- `backend/UniVerse.Infrastructure` — EF Core, миграции, реализации сервисов, внешние клиенты
|
||||||
- Управление лекциями и создание новых занятий.
|
|
||||||
- Синхронизация расписания, аудиторий и преподавателей из Modeus.
|
|
||||||
- Модерация отзывов, повторный запуск LLM-анализа и настройка промпта.
|
|
||||||
- Управление курсами, тегами, локациями и достижениями через API.
|
|
||||||
|
|
||||||
### Платформенные функции
|
|
||||||
|
|
||||||
- Microsoft Entra ID login и dev-login для локальной разработки.
|
|
||||||
- JWT access token и refresh flow через cookie.
|
|
||||||
- Ролевая защита маршрутов frontend и endpoint-level авторизация backend.
|
|
||||||
- Фоновая очередь анализа отзывов через LLM.
|
|
||||||
- Геймификация: XP, уровни, монеты, достижения и транзакции.
|
|
||||||
- Email-уведомления и планировщик Quartz.
|
|
||||||
- Rate limiting, Serilog, Prometheus metrics и Swagger UI в Development.
|
|
||||||
- Aspire AppHost для совместного локального запуска API и Vite frontend.
|
|
||||||
- Docker Compose для production-окружения с backend, frontend и PostgreSQL.
|
|
||||||
|
|
||||||
## Архитектура
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
Student[Student UI] --> Frontend[Vue 3 frontend]
|
|
||||||
Teacher[Teacher UI] --> Frontend
|
|
||||||
Admin[Admin UI] --> Frontend
|
|
||||||
|
|
||||||
Frontend -->|/api/v1 JSON| Api[ASP.NET Core Web API]
|
|
||||||
Api --> Auth[Auth and RBAC]
|
|
||||||
Api --> App[Application services]
|
|
||||||
App --> Domain[Domain model]
|
|
||||||
App --> Infra[Infrastructure]
|
|
||||||
Infra --> Db[(PostgreSQL)]
|
|
||||||
Infra --> Llm[OpenAI-compatible LLM]
|
|
||||||
Infra --> Modeus[Modeus schedule API]
|
|
||||||
Infra --> Mail[SMTP email]
|
|
||||||
|
|
||||||
Api --> Quartz[Quartz jobs]
|
|
||||||
Quartz --> Mail
|
|
||||||
Api --> Metrics[Prometheus /metrics]
|
|
||||||
```
|
|
||||||
|
|
||||||
Backend следует Clean Architecture:
|
|
||||||
|
|
||||||
- `UniVerse.Api` - controllers, middleware, Swagger, DI, background services.
|
|
||||||
- `UniVerse.Application` - DTO, interfaces, service contracts, mappings, prompts.
|
|
||||||
- `UniVerse.Domain` - entities, enums, domain exceptions, domain services.
|
|
||||||
- `UniVerse.Infrastructure` - EF Core, migrations, external clients, notification and business service implementations.
|
|
||||||
- `UniVerse.AppHost` - Aspire host для локального запуска API и frontend.
|
|
||||||
- `UniVerse.Api.Tests` - unit и integration tests.
|
|
||||||
|
|
||||||
Frontend построен на Vue 3, TypeScript, Vite, Pinia и Vue Router:
|
|
||||||
|
|
||||||
- `src/views/student` - кабинет студента, каталог, карточка лекции, мои лекции, отзывы, профиль, уведомления.
|
|
||||||
- `src/views/teacher` - кабинет преподавателя, лекции, аналитика.
|
|
||||||
- `src/views/admin` - дашборд, пользователи, лекции, отзывы.
|
|
||||||
- `src/api` - typed API client, DTO-типы и мапперы.
|
|
||||||
- `src/components/ui` и `src/components/layout` - переиспользуемые UI и layout-компоненты.
|
|
||||||
|
|
||||||
## Сценарий записи и анализа отзыва
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
actor S as Student
|
|
||||||
participant UI as Vue frontend
|
|
||||||
participant API as UniVerse API
|
|
||||||
participant DB as PostgreSQL
|
|
||||||
participant Q as ReviewAnalysisQueue
|
|
||||||
participant LLM as LLM API
|
|
||||||
participant G as GamificationService
|
|
||||||
|
|
||||||
S->>UI: Выбирает открытую лекцию
|
|
||||||
UI->>API: POST /api/v1/lectures/{id}/enroll
|
|
||||||
API->>DB: Проверяет лимиты и сохраняет запись
|
|
||||||
API-->>UI: 204 No Content
|
|
||||||
S->>UI: Оставляет отзыв
|
|
||||||
UI->>API: POST /api/v1/reviews
|
|
||||||
API->>DB: Сохраняет отзыв со статусом Pending
|
|
||||||
API->>Q: Ставит отзыв в очередь анализа
|
|
||||||
Q->>LLM: Анализ качества, тональности и тегов
|
|
||||||
LLM-->>Q: Результат анализа
|
|
||||||
Q->>DB: Обновляет отзыв
|
|
||||||
Q->>G: Начисляет XP, монеты и достижения
|
|
||||||
G->>DB: Сохраняет транзакции и награды
|
|
||||||
```
|
|
||||||
|
|
||||||
Основные группы таблиц:
|
|
||||||
|
|
||||||
- Пользователи и доступ: `users`, `user_roles`, `student_profiles`, `teacher_profiles`, `refresh_tokens`.
|
|
||||||
- Расписание: `courses`, `lectures`, `locations`, `tags`, `course_tags`.
|
|
||||||
- Участие и обратная связь: `lecture_enrollments`, `reviews`, `review_prompt_settings`.
|
|
||||||
- Геймификация: `achievements`, `user_achievements`, `coin_transactions`, `level_thresholds`.
|
|
||||||
- Уведомления: `user_notifications`.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
Базовый префикс: `/api/v1`.
|
|
||||||
|
|
||||||
- `/auth` - Microsoft login, dev-login, refresh, logout, текущий пользователь.
|
|
||||||
- `/users` - профиль, статистика, роли, активность, записи, достижения, транзакции, `.ics`.
|
|
||||||
- `/lectures` - каталог лекций, CRUD, запись, посещаемость, отзывы по лекции.
|
|
||||||
- `/reviews` - создание, список, модерация, повторный анализ, настройка LLM-промпта.
|
|
||||||
- `/courses` - курсы и привязка тегов.
|
|
||||||
- `/tags` - теги и дерево тегов.
|
|
||||||
- `/locations` - аудитории и локации.
|
|
||||||
- `/achievements` - каталог достижений.
|
|
||||||
- `/notifications` - уведомления, отметка прочтения, отправка и планирование.
|
|
||||||
- `/sync` - синхронизация расписания, аудиторий и преподавателей из Modeus.
|
|
||||||
|
|
||||||
В Development Swagger UI доступен по адресу `http://localhost:5019/api/docs`.
|
|
||||||
|
|
||||||
## Стек
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- Vue 3, TypeScript, Vite.
|
|
||||||
- Pinia, Vue Router.
|
|
||||||
- Playwright E2E tests.
|
|
||||||
- Nginx для production-раздачи и проксирования.
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- .NET 10, ASP.NET Core Web API.
|
|
||||||
- EF Core 10, Npgsql, PostgreSQL.
|
|
||||||
- Swashbuckle/OpenAPI.
|
|
||||||
- Serilog, Prometheus, ASP.NET Core Rate Limiting.
|
|
||||||
- Quartz для отложенных уведомлений.
|
|
||||||
- Aspire AppHost.
|
|
||||||
- xUnit, NSubstitute, WebApplicationFactory.
|
|
||||||
|
|
||||||
### Интеграции
|
|
||||||
|
|
||||||
- Microsoft Entra ID.
|
|
||||||
- Modeus schedule API.
|
|
||||||
- OpenAI-compatible LLM API.
|
|
||||||
- SMTP email.
|
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- .NET SDK 10.x.
|
- .NET SDK 10 (`dotnet --version` должен показать `10.x`)
|
||||||
- Node.js `^20.19.0 || >=22.12.0`.
|
- PostgreSQL 14+ (или Docker для поднятия Postgres)
|
||||||
- pnpm.
|
|
||||||
- PostgreSQL 17+ или Docker.
|
|
||||||
- Для production - Docker Engine и Docker Compose.
|
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
Backend читает настройки из `backend/UniVerse.Api/appsettings.json`, `appsettings.Development.json` и переменных окружения в формате `Section__Key`.
|
Основные настройки лежат в `backend/UniVerse.Api/appsettings.json`:
|
||||||
|
|
||||||
Основные секции:
|
- `ConnectionStrings:DefaultConnection` — строка подключения к Postgres
|
||||||
|
- `Jwt:*` — секрет/issuer/audience и сроки жизни токенов
|
||||||
|
- `Cors:Origins` — origin’ы фронтенда
|
||||||
|
- `Llm:*` — настройки LLM (OpenAI-compatible)
|
||||||
|
- `ModeusApi:*` — настройки интеграции с Modeus
|
||||||
|
|
||||||
- `ConnectionStrings:DefaultConnection` - подключение к PostgreSQL.
|
Можно переопределять через переменные окружения в формате `Section__Key`, например:
|
||||||
- `Jwt:*` - issuer, audience, secret и сроки жизни токенов.
|
|
||||||
- `AzureAd:*` - Microsoft Entra ID.
|
|
||||||
- `Cors:Origins` - разрешенные origin frontend.
|
|
||||||
- `RateLimiting:*` - глобальный fixed-window limiter.
|
|
||||||
- `Llm:*` - base URL, API key, model и параметры анализа отзывов.
|
|
||||||
- `ModeusApi:*` - base URL, API key и timeout.
|
|
||||||
- `Email:Smtp:*` - SMTP-настройки для уведомлений.
|
|
||||||
|
|
||||||
Frontend использует переменные:
|
- `ConnectionStrings__DefaultConnection`
|
||||||
|
- `Jwt__Secret`
|
||||||
|
- `Llm__ApiKey`
|
||||||
|
- `ModeusApi__ApiKey`
|
||||||
|
|
||||||
- `VITE_API_BASE_URL` - базовый адрес API, по умолчанию `/api`.
|
## Быстрый старт (локально)
|
||||||
- `VITE_API_PROXY_TARGET` - target для Vite proxy при запуске через Aspire.
|
|
||||||
- `VITE_AUTH_RETURN_URL` - frontend callback URL, по умолчанию `/auth/callback`.
|
|
||||||
|
|
||||||
## Быстрый старт
|
1) Поднять Postgres (пример через Docker):
|
||||||
|
|
||||||
### 1. Установить зависимости frontend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm -C frontend install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Поднять PostgreSQL
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm --name universe-postgres \
|
docker run --rm --name universe-postgres \
|
||||||
@@ -214,7 +64,7 @@ docker run --rm --name universe-postgres \
|
|||||||
postgres:18
|
postgres:18
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Применить миграции
|
2) Применить миграции (первый раз потребуется `dotnet-ef`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet tool install --global dotnet-ef
|
dotnet tool install --global dotnet-ef
|
||||||
@@ -225,71 +75,91 @@ dotnet ef database update \
|
|||||||
--startup-project UniVerse.Api
|
--startup-project UniVerse.Api
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Запустить backend
|
3) Запустить API:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project backend/UniVerse.Api --launch-profile http
|
cd backend
|
||||||
|
dotnet run --project UniVerse.Api --launch-profile http
|
||||||
```
|
```
|
||||||
|
|
||||||
API по умолчанию слушает `http://localhost:5019`.
|
По умолчанию (профиль `http`) API поднимется на `http://localhost:5019`.
|
||||||
|
Swagger UI доступен в Development по адресу: `http://localhost:5019/swagger`.
|
||||||
|
|
||||||
### 5. Запустить frontend
|
## Запуск в Docker
|
||||||
|
|
||||||
|
В `backend/UniVerse.Api/Dockerfile` настроена сборка контейнера API.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm -C frontend dev
|
cd backend
|
||||||
|
docker build -f UniVerse.Api/Dockerfile -t universe-api .
|
||||||
|
|
||||||
|
docker run --rm -p 8080:8080 \
|
||||||
|
-e ASPNETCORE_URLS=http://+:8080 \
|
||||||
|
-e ConnectionStrings__DefaultConnection="Host=host.docker.internal;Port=5432;Database=universe;Username=postgres;Password=postgres" \
|
||||||
|
universe-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Vite frontend по умолчанию слушает `http://localhost:5173` и проксирует `/api` на `http://localhost:5019`.
|
Примечание: на Linux `host.docker.internal` может быть недоступен — проще запускать Postgres тоже в Docker и соединять контейнеры в одной сети.
|
||||||
|
|
||||||
## Запуск через Aspire
|
## Аутентификация
|
||||||
|
|
||||||
Aspire AppHost запускает API и Vite frontend вместе:
|
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
|
||||||
|
- `GET /api/v1/auth/login/microsoft` — старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft).
|
||||||
|
- `GET /api/v1/auth/callback/microsoft` — callback, куда Microsoft возвращает `code`.
|
||||||
|
- `POST /api/v1/auth/login/microsoft` — обмен `authorizationCode` на токены (полезно для интеграций/ручных тестов). Тело: `{ "authorizationCode": "...", "redirectUri"?: "..." }`.
|
||||||
|
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
|
||||||
|
|
||||||
```bash
|
Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`).
|
||||||
pnpm -C frontend install
|
|
||||||
dotnet run --project backend/UniVerse.AppHost/UniVerse.AppHost.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend обычно доступен на `http://localhost:5173`. Target API передается во frontend через `VITE_API_PROXY_TARGET`.
|
Большинство методов API защищены `[Authorize]`.
|
||||||
|
|
||||||
## Docker Compose
|
## Фоновый LLM-анализ отзывов
|
||||||
|
|
||||||
Production compose описан в `docker-compose-prod.yml`:
|
Сервис `LlmProcessingBackgroundService` раз в ~2 минуты берёт отзывы со статусом `Pending` и прогоняет через LLM-клиент.
|
||||||
|
LLM-ключ задаётся через `Llm:ApiKey`.
|
||||||
|
|
||||||
- `app` - ASP.NET Core backend.
|
Если ключ не задан или внешний сервис недоступен — анализ будет ретраиться, а ошибки логироваться.
|
||||||
- `frontend` - собранный Vue frontend и Nginx.
|
|
||||||
- `db` - PostgreSQL.
|
|
||||||
|
|
||||||
Перед запуском задайте переменные окружения для PostgreSQL, JWT, Microsoft auth, CORS и внешних интеграций:
|
## Интеграция с Modeus
|
||||||
|
|
||||||
```bash
|
Эндпоинты синхронизации доступны только администратору:
|
||||||
docker compose -f docker-compose-prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Тестовый compose находится в `docker-compose-test.yml`.
|
- `POST /api/v1/sync/schedule`
|
||||||
|
- `POST /api/v1/sync/rooms`
|
||||||
|
- `POST /api/v1/sync/employees`
|
||||||
|
- `GET /api/v1/sync/status`
|
||||||
|
|
||||||
|
Ключ (если нужен) задаётся через `ModeusApi:ApiKey`.
|
||||||
|
|
||||||
|
## Карта API (high-level)
|
||||||
|
|
||||||
|
Базовый префикс: `/api/v1`.
|
||||||
|
|
||||||
|
- `/auth` — логин/refresh/logout/me
|
||||||
|
- `/users` — профиль/статистика/достижения/транзакции (часть методов — только `Admin`)
|
||||||
|
- `/courses` — курсы и теги (CRUD в основном для `Admin`)
|
||||||
|
- `/lectures` — лекции, записи, посещаемость, отзывы
|
||||||
|
- `/reviews` — отзывы (создание студентом; модерация/реанализ для `Admin`)
|
||||||
|
- `/tags` — теги + дерево тегов
|
||||||
|
- `/locations` — аудитории/локации
|
||||||
|
- `/achievements` — достижения
|
||||||
|
- `/sync` — синхронизация с внешним расписанием (только `Admin`)
|
||||||
|
|
||||||
|
Точные схемы запросов/ответов удобнее смотреть в Swagger.
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
Backend:
|
В проекте настроено модульное и интеграционное тестирование (папка `backend/UniVerse.Api.Tests`):
|
||||||
|
|
||||||
|
- **xUnit** в качестве основного фреймворка для тестирования.
|
||||||
|
- **NSubstitute** для создания заглушек (моков) зависимостей сервисов.
|
||||||
|
- Используется `WebApplicationFactory` (`ApiWebApplicationFactory.cs`) для поднятия интеграционного тестового сервера с подменой БД на `InMemory` и отключенными фоновыми сервисами (например, LLM-интеграциями) для изоляции.
|
||||||
|
- Реализованы полные тесты ролевой модели и авторизации (`EndpointAuthorizationTests.cs`), надежно проверяющие все API-конечные точки на политики доступа от имени различных ролей (`Admin`, `Teacher`, `Student`, `Anonymous`).
|
||||||
|
|
||||||
|
Запуск тестов:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet test backend/UniVerse.sln
|
cd backend
|
||||||
|
dotnet test
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend type-check и production build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm -C frontend build
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend E2E с mock API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm -C frontend test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Load testing helper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node frontend/scripts/loadtest-endpoints.js
|
|
||||||
```
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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,
|
|
||||||
MandatoryAttendeesCount = 30,
|
|
||||||
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(32, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,9 +47,6 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
|
|||||||
body: """{"displayName":"Test","avatarUrl":null}""");
|
body: """{"displayName":"Test","avatarUrl":null}""");
|
||||||
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
|
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
|
||||||
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
|
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
|
||||||
yield return E("users/me/enrollments/calendar-subscription [AnyAuth]", "GET", "api/v1/users/me/enrollments/calendar-subscription", "Student");
|
|
||||||
yield return E("users/me/enrollments.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments.ics", "Student");
|
|
||||||
yield return E("users/me/enrollments/{id}.ics [AnyAuth]", "GET", "api/v1/users/me/enrollments/1.ics", "Student");
|
|
||||||
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
|
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
|
||||||
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
|
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
|
||||||
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
|
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
|
||||||
@@ -195,7 +192,6 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
|
|||||||
// dev login доступен в окружении Development
|
// dev login доступен в окружении Development
|
||||||
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
|
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
|
||||||
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
|
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
|
||||||
yield return new object[] { "users/calendar/enrollments/{token}.ics GET", "GET", "api/v1/users/calendar/enrollments/bad-token.ics" };
|
|
||||||
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
|
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
|
||||||
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
|
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
|
||||||
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
|
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
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<ConflictException>(() => 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<NotFoundException>(() => service.AddTagAsync(404, 10));
|
|
||||||
await Assert.ThrowsAsync<NotFoundException>(() => service.AddTagAsync(1, 404));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AppDbContext CreateDbContext()
|
|
||||||
{
|
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
|
||||||
.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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ using UniVerse.Application.DTOs.Tags;
|
|||||||
using UniVerse.Application.DTOs.Users;
|
using UniVerse.Application.DTOs.Users;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Enums;
|
using UniVerse.Domain.Enums;
|
||||||
using UniVerse.Domain.Exceptions;
|
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
|
|
||||||
namespace UniVerse.Api.Tests.Helpers;
|
namespace UniVerse.Api.Tests.Helpers;
|
||||||
@@ -178,13 +177,6 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
3,
|
3,
|
||||||
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
|
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
|
||||||
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
|
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedLectures);
|
||||||
stub.GetMyEnrollmentsIcsAsync(Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
|
|
||||||
stub.GetEnrollmentIcsAsync(Arg.Any<int>(), Arg.Any<int>()).Returns("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
|
|
||||||
stub.GetCalendarSubscriptionTokenAsync(Arg.Any<int>()).Returns("test-token");
|
|
||||||
stub.GetEnrollmentsIcsBySubscriptionTokenAsync("bad-token")
|
|
||||||
.Returns(Task.FromException<string>(new ForbiddenException("Invalid calendar subscription token.")));
|
|
||||||
stub.GetEnrollmentsIcsBySubscriptionTokenAsync(Arg.Is<string>(token => token != "bad-token"))
|
|
||||||
.Returns(Task.FromResult("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"));
|
|
||||||
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
|
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
|
||||||
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
|
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
|
||||||
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||||
|
|||||||
@@ -166,29 +166,6 @@ public class LectureServiceTests
|
|||||||
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
|
Assert.True(await db.LectureEnrollments.AnyAsync(e => e.LectureId == 100 && e.UserId == 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task EnrollAsync_CountsMandatoryAttendeesTowardLectureCapacity()
|
|
||||||
{
|
|
||||||
await using var db = CreateDbContext();
|
|
||||||
var gamification = Substitute.For<IGamificationService>();
|
|
||||||
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
|
|
||||||
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
|
|
||||||
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
|
|
||||||
lecture.MaxEnrollments = 31;
|
|
||||||
lecture.MandatoryAttendeesCount = 30;
|
|
||||||
|
|
||||||
db.Users.AddRange(
|
|
||||||
new User { Id = 1, Email = "first@test.local" },
|
|
||||||
new User { Id = 2, Email = "second@test.local" });
|
|
||||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
|
||||||
db.Lectures.Add(lecture);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
await service.EnrollAsync(1, 1);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(1, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UnenrollAsync_CancelsLectureReminders()
|
public async Task UnenrollAsync_CancelsLectureReminders()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using UniVerse.Api.Tests.Helpers;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Tests.RateLimiting;
|
|
||||||
|
|
||||||
public class RateLimitingTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task GlobalRateLimiter_Returns429_WhenPartitionExceedsLimit()
|
|
||||||
{
|
|
||||||
await using var factory = new ApiWebApplicationFactory()
|
|
||||||
.WithWebHostBuilder(builder =>
|
|
||||||
{
|
|
||||||
builder.ConfigureAppConfiguration((_, config) =>
|
|
||||||
{
|
|
||||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["RateLimiting:PermitLimit"] = "1",
|
|
||||||
["RateLimiting:WindowSeconds"] = "60",
|
|
||||||
["RateLimiting:QueueLimit"] = "0"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
using var client = factory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Add("Authorization", TestJwtFactory.BearerHeader("Student"));
|
|
||||||
|
|
||||||
using var firstResponse = await client.GetAsync("api/v1/tags");
|
|
||||||
using var secondResponse = await client.GetAsync("api/v1/tags");
|
|
||||||
|
|
||||||
Assert.NotEqual(HttpStatusCode.TooManyRequests, firstResponse.StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using UniVerse.Application.DTOs.Sync;
|
|
||||||
using UniVerse.Infrastructure.ExternalServices;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Tests.Sync;
|
|
||||||
|
|
||||||
public class ModeusApiClientTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task SearchEventsAsync_RequestsIctisEndpointWithCounts()
|
|
||||||
{
|
|
||||||
var handler = new CapturingHandler();
|
|
||||||
var http = new HttpClient(handler)
|
|
||||||
{
|
|
||||||
BaseAddress = new Uri("https://schedule.test")
|
|
||||||
};
|
|
||||||
var config = new ConfigurationBuilder().Build();
|
|
||||||
var client = new ModeusApiClient(http, config, NullLogger<ModeusApiClient>.Instance);
|
|
||||||
|
|
||||||
await client.SearchEventsAsync(new SyncScheduleRequest(
|
|
||||||
SpecialtyCode: ["09.03.04"],
|
|
||||||
TimeMin: new DateTime(2026, 4, 30, 21, 0, 0, DateTimeKind.Utc),
|
|
||||||
TimeMax: new DateTime(2026, 6, 13, 20, 59, 0, DateTimeKind.Utc),
|
|
||||||
TypeId: ["LECT"],
|
|
||||||
Size: 50));
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Post, handler.RequestMethod);
|
|
||||||
Assert.Equal("/api/ictis?includeCounts=true", handler.RequestPathAndQuery);
|
|
||||||
Assert.NotNull(handler.RequestBody);
|
|
||||||
using var body = JsonDocument.Parse(handler.RequestBody);
|
|
||||||
Assert.Equal(50, body.RootElement.GetProperty("size").GetInt32());
|
|
||||||
Assert.Equal("09.03.04", body.RootElement.GetProperty("specialtyCode")[0].GetString());
|
|
||||||
Assert.Equal("LECT", body.RootElement.GetProperty("typeId")[0].GetString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CapturingHandler : HttpMessageHandler
|
|
||||||
{
|
|
||||||
public HttpMethod? RequestMethod { get; private set; }
|
|
||||||
public string? RequestPathAndQuery { get; private set; }
|
|
||||||
public string? RequestBody { get; private set; }
|
|
||||||
|
|
||||||
protected override async Task<HttpResponseMessage> SendAsync(
|
|
||||||
HttpRequestMessage request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
RequestMethod = request.Method;
|
|
||||||
RequestPathAndQuery = request.RequestUri?.PathAndQuery;
|
|
||||||
RequestBody = request.Content is null
|
|
||||||
? null
|
|
||||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
|
|
||||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new StringContent("""{"events":[]}""")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -129,56 +129,6 @@ public class ScheduleSyncServiceTests
|
|||||||
Assert.Equal(48, lecture.MaxEnrollments);
|
Assert.Equal(48, lecture.MaxEnrollments);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SyncScheduleAsync_SavesMandatoryAttendeesFromIctisStats()
|
|
||||||
{
|
|
||||||
await using var db = CreateDbContext();
|
|
||||||
var modeus = Substitute.For<IModeusApiClient>();
|
|
||||||
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>())
|
|
||||||
.Returns(new ModeusEventsResponse
|
|
||||||
{
|
|
||||||
Embedded = new ModeusEventsEmbedded
|
|
||||||
{
|
|
||||||
Events =
|
|
||||||
[
|
|
||||||
new ModeusEvent
|
|
||||||
{
|
|
||||||
Id = "event-1",
|
|
||||||
Name = "Open lecture",
|
|
||||||
StartsAt = new DateTime(2026, 5, 13, 9, 0, 0, DateTimeKind.Utc),
|
|
||||||
EndsAt = new DateTime(2026, 5, 13, 10, 30, 0, DateTimeKind.Utc),
|
|
||||||
IctisStats = new ModeusIctisStats(StudentCount: 30, TeacherCount: 1)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
EventRooms =
|
|
||||||
[
|
|
||||||
new ModeusEventRoom
|
|
||||||
{
|
|
||||||
Links = new ModeusEventRoomLinks
|
|
||||||
{
|
|
||||||
Event = new ModeusHrefLink("/events/event-1"),
|
|
||||||
Room = new ModeusHrefLink("/rooms/room-1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
Rooms =
|
|
||||||
[
|
|
||||||
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 120, WorkingCapacity: 120)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
|
|
||||||
|
|
||||||
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
|
|
||||||
|
|
||||||
var lecture = await db.Lectures.SingleAsync();
|
|
||||||
Assert.Null(result.Error);
|
|
||||||
Assert.Equal(1, result.Created);
|
|
||||||
Assert.Equal(120, lecture.MaxEnrollments);
|
|
||||||
Assert.Equal(31, lecture.MandatoryAttendeesCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
|
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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<NotFoundException>(() =>
|
|
||||||
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<AppDbContext>()
|
|
||||||
.UseInMemoryDatabase($"TagServiceTests_{Guid.NewGuid()}")
|
|
||||||
.Options;
|
|
||||||
return new AppDbContext(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,12 +9,12 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using UniVerse.Application.DTOs.Notifications;
|
using UniVerse.Application.DTOs.Notifications;
|
||||||
using UniVerse.Application.DTOs.Users;
|
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Entities;
|
using UniVerse.Domain.Entities;
|
||||||
using UniVerse.Domain.Enums;
|
|
||||||
using UniVerse.Domain.Exceptions;
|
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -75,175 +71,6 @@ public class UserServiceTests
|
|||||||
Assert.Equal(new[] { 3, 5, 7 }, stats.EnrollmentSlotRules.Select(rule => rule.Slots));
|
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<ForbiddenException>(() => 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CalendarSubscriptionToken_Roundtrip_ReturnsUserEnrollmentsIcs()
|
|
||||||
{
|
|
||||||
await using var db = CreateDbContext();
|
|
||||||
var startsAt = new DateTime(2026, 1, 10, 9, 0, 0, DateTimeKind.Utc);
|
|
||||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
|
||||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
|
||||||
db.Lectures.Add(Lecture(1, startsAt));
|
|
||||||
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
var service = CreateService(db);
|
|
||||||
|
|
||||||
var token = await service.GetCalendarSubscriptionTokenAsync(1);
|
|
||||||
var ics = await service.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
|
|
||||||
|
|
||||||
Assert.Contains("BEGIN:VCALENDAR", ics);
|
|
||||||
Assert.Contains("Lecture 1", ics);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CalendarSubscriptionToken_RejectsTamperedToken()
|
|
||||||
{
|
|
||||||
await using var db = CreateDbContext();
|
|
||||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
var service = CreateService(db);
|
|
||||||
var token = await service.GetCalendarSubscriptionTokenAsync(1);
|
|
||||||
var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A');
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ForbiddenException>(() =>
|
|
||||||
service.GetEnrollmentsIcsBySubscriptionTokenAsync(tampered));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetEnrollmentIcsAsync_ReturnsLectureIcsWithoutEnrollment()
|
|
||||||
{
|
|
||||||
await using var db = CreateDbContext();
|
|
||||||
var startsAt = new DateTime(2026, 2, 10, 9, 0, 0, DateTimeKind.Utc);
|
|
||||||
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
|
|
||||||
db.Courses.Add(new Course { Id = 1, Name = "Course" });
|
|
||||||
db.Lectures.Add(Lecture(1853, startsAt));
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
var service = CreateService(db);
|
|
||||||
|
|
||||||
var ics = await service.GetEnrollmentIcsAsync(1, 1853);
|
|
||||||
|
|
||||||
Assert.Contains("BEGIN:VCALENDAR", ics);
|
|
||||||
Assert.Contains("Lecture 1853", ics);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AppDbContext CreateDbContext()
|
private static AppDbContext CreateDbContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
@@ -266,13 +93,7 @@ public class UserServiceTests
|
|||||||
.Returns(Task.CompletedTask);
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
|
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
|
||||||
var config = new ConfigurationBuilder()
|
return new UserService(db, gamification);
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["Jwt:Secret"] = "test-calendar-subscription-secret-32chars"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
return new UserService(db, gamification, config);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SeedLevelThresholds(AppDbContext db)
|
private static void SeedLevelThresholds(AppDbContext db)
|
||||||
@@ -294,31 +115,4 @@ public class UserServiceTests
|
|||||||
IsOpen = true,
|
IsOpen = true,
|
||||||
MaxEnrollments = 30
|
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()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using UniVerse.Application.DTOs.Users;
|
|||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Enums;
|
using UniVerse.Domain.Enums;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
|
|
||||||
@@ -84,52 +83,6 @@ public class UsersController : ControllerBase
|
|||||||
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest pagination) =>
|
||||||
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
|
Ok(await _users.GetEnrollmentsAsync(CurrentUserId, pagination));
|
||||||
|
|
||||||
[HttpGet("me/enrollments/calendar-subscription")]
|
|
||||||
[ProducesResponseType(typeof(CalendarSubscriptionDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
public async Task<ActionResult<CalendarSubscriptionDto>> CalendarSubscription()
|
|
||||||
{
|
|
||||||
var token = await _users.GetCalendarSubscriptionTokenAsync(CurrentUserId);
|
|
||||||
var feedUrl = Url.Action(
|
|
||||||
nameof(CalendarEnrollmentsIcs),
|
|
||||||
null,
|
|
||||||
new { token },
|
|
||||||
Request.Scheme)
|
|
||||||
?? $"{Request.Scheme}://{Request.Host}/api/v1/users/calendar/enrollments/{token}.ics";
|
|
||||||
|
|
||||||
return Ok(new CalendarSubscriptionDto(feedUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpGet("calendar/enrollments/{token}.ics")]
|
|
||||||
[Produces("text/calendar")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<FileContentResult> CalendarEnrollmentsIcs(string token)
|
|
||||||
{
|
|
||||||
var ics = await _users.GetEnrollmentsIcsBySubscriptionTokenAsync(token);
|
|
||||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("me/enrollments.ics")]
|
|
||||||
[Produces("text/calendar")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
public async Task<FileContentResult> MyEnrollmentsIcs()
|
|
||||||
{
|
|
||||||
var ics = await _users.GetMyEnrollmentsIcsAsync(CurrentUserId);
|
|
||||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", "my-lectures.ics");
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("me/enrollments/{lectureId:int}.ics")]
|
|
||||||
[Produces("text/calendar")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<FileContentResult> EnrollmentIcs(int lectureId)
|
|
||||||
{
|
|
||||||
var ics = await _users.GetEnrollmentIcsAsync(CurrentUserId, lectureId);
|
|
||||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"lecture-{lectureId}.ics");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Получить отзывы текущего пользователя.</summary>
|
/// <summary>Получить отзывы текущего пользователя.</summary>
|
||||||
/// <param name="pagination">Параметры пагинации.</param>
|
/// <param name="pagination">Параметры пагинации.</param>
|
||||||
/// <response code="200">Список отзывов (пагинированный).</response>
|
/// <response code="200">Список отзывов (пагинированный).</response>
|
||||||
@@ -206,19 +159,6 @@ public class UsersController : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
public async Task<ActionResult<UserStatsDto>> Stats(int id) => Ok(await _users.GetStatsAsync(id));
|
||||||
|
|
||||||
/// <summary>Получить статистику для админского дашборда.</summary>
|
|
||||||
/// <remarks>Только Admin.</remarks>
|
|
||||||
/// <response code="200">Агрегированная статистика дашборда.</response>
|
|
||||||
/// <response code="401">Требуется аутентификация.</response>
|
|
||||||
/// <response code="403">Требуется роль Admin.</response>
|
|
||||||
[Authorize(Roles = "Admin")]
|
|
||||||
[HttpGet("admin/stats")]
|
|
||||||
[ProducesResponseType(typeof(AdminDashboardStatsDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ActionResult<AdminDashboardStatsDto>> AdminStats() =>
|
|
||||||
Ok(await _users.GetAdminDashboardStatsAsync());
|
|
||||||
|
|
||||||
/// <summary>Получить список записей пользователя на лекции.</summary>
|
/// <summary>Получить список записей пользователя на лекции.</summary>
|
||||||
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
|
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
|
||||||
/// <param name="id">ID пользователя.</param>
|
/// <param name="id">ID пользователя.</param>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
|
|
||||||
namespace UniVerse.Api.Middleware;
|
|
||||||
|
|
||||||
public sealed class LocalNetworksOnlyMiddleware
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
|
|
||||||
|
|
||||||
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> logger)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
var remoteIpAddress = context.Connection.RemoteIpAddress;
|
|
||||||
|
|
||||||
if (remoteIpAddress is null || !IsLocalNetwork(remoteIpAddress))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Blocked metrics request from non-local address {RemoteIpAddress}", remoteIpAddress);
|
|
||||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
||||||
await context.Response.WriteAsync("Metrics endpoint is available only from local networks.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsLocalNetwork(IPAddress ipAddress)
|
|
||||||
{
|
|
||||||
if (IPAddress.IsLoopback(ipAddress))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ipAddress.IsIPv4MappedToIPv6)
|
|
||||||
{
|
|
||||||
ipAddress = ipAddress.MapToIPv4();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ipAddress.AddressFamily switch
|
|
||||||
{
|
|
||||||
AddressFamily.InterNetwork => IsPrivateOrLinkLocalIPv4(ipAddress),
|
|
||||||
AddressFamily.InterNetworkV6 => IsPrivateOrLinkLocalIPv6(ipAddress),
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsPrivateOrLinkLocalIPv4(IPAddress ipAddress)
|
|
||||||
{
|
|
||||||
var bytes = ipAddress.GetAddressBytes();
|
|
||||||
|
|
||||||
return bytes[0] == 10
|
|
||||||
|| bytes[0] == 127
|
|
||||||
|| (bytes[0] == 192 && bytes[1] == 168)
|
|
||||||
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
|
||||||
|| (bytes[0] == 169 && bytes[1] == 254);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsPrivateOrLinkLocalIPv6(IPAddress ipAddress)
|
|
||||||
{
|
|
||||||
var bytes = ipAddress.GetAddressBytes();
|
|
||||||
|
|
||||||
return ipAddress.IsIPv6LinkLocal
|
|
||||||
|| ipAddress.IsIPv6SiteLocal
|
|
||||||
|| (bytes[0] & 0xfe) == 0xfc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace UniVerse.Api.Options;
|
|
||||||
|
|
||||||
public class RateLimitingOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "RateLimiting";
|
|
||||||
|
|
||||||
public int PermitLimit { get; set; } = 600;
|
|
||||||
|
|
||||||
public int WindowSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
public int QueueLimit { get; set; } = 100;
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Prometheus;
|
|
||||||
using Quartz;
|
using Quartz;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
using UniVerse.Api.Filters;
|
using UniVerse.Api.Filters;
|
||||||
using UniVerse.Api.Middleware;
|
using UniVerse.Api.Middleware;
|
||||||
@@ -73,50 +68,6 @@ builder.Services.AddAuthentication(options =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
builder.Services.AddOptions<RateLimitingOptions>()
|
|
||||||
.Bind(builder.Configuration.GetSection(RateLimitingOptions.SectionName))
|
|
||||||
.Validate(options => options.PermitLimit >= 1,
|
|
||||||
"RateLimiting:PermitLimit must be greater than or equal to 1.")
|
|
||||||
.Validate(options => options.WindowSeconds >= 1,
|
|
||||||
"RateLimiting:WindowSeconds must be greater than or equal to 1.")
|
|
||||||
.Validate(options => options.QueueLimit >= 0,
|
|
||||||
"RateLimiting:QueueLimit must be greater than or equal to 0.")
|
|
||||||
.ValidateOnStart();
|
|
||||||
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
|
||||||
{
|
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
||||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
|
||||||
{
|
|
||||||
var rateLimitingOptions = context.RequestServices.GetRequiredService<IOptions<RateLimitingOptions>>().Value;
|
|
||||||
return RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
GetRateLimitPartitionKey(context),
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = rateLimitingOptions.PermitLimit,
|
|
||||||
Window = TimeSpan.FromSeconds(rateLimitingOptions.WindowSeconds),
|
|
||||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
||||||
QueueLimit = rateLimitingOptions.QueueLimit,
|
|
||||||
AutoReplenishment = true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
options.OnRejected = async (context, cancellationToken) =>
|
|
||||||
{
|
|
||||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
|
||||||
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
|
|
||||||
|
|
||||||
context.HttpContext.Response.ContentType = "application/problem+json";
|
|
||||||
await context.HttpContext.Response.WriteAsJsonAsync(new
|
|
||||||
{
|
|
||||||
type = "https://httpstatuses.com/429",
|
|
||||||
title = "Too Many Requests",
|
|
||||||
status = StatusCodes.Status429TooManyRequests,
|
|
||||||
detail = "Rate limit exceeded. Please try again later.",
|
|
||||||
traceId = context.HttpContext.TraceIdentifier
|
|
||||||
}, cancellationToken);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- CORS ---
|
// --- CORS ---
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@@ -184,7 +135,7 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
|
|||||||
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
|
client.BaseAddress = new Uri(builder.Configuration["ModeusApi:BaseUrl"] ?? "https://schedule.rdcenter.ru");
|
||||||
client.Timeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("ModeusApi:TimeoutSeconds", 180));
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Background Services ---
|
// --- Background Services ---
|
||||||
@@ -269,9 +220,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseRateLimiter();
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseHttpMetrics();
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
@@ -279,22 +228,4 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
// Restrict Prometheus scrape endpoint to local and private networks.
|
|
||||||
app.UseWhen(
|
|
||||||
context => context.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase),
|
|
||||||
branch => branch.UseMiddleware<LocalNetworksOnlyMiddleware>());
|
|
||||||
app.MapMetrics();
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
static string GetRateLimitPartitionKey(HttpContext context)
|
|
||||||
{
|
|
||||||
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
||||||
?? context.User.FindFirstValue("sub");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(userId))
|
|
||||||
return $"user:{userId}";
|
|
||||||
|
|
||||||
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
|
|
||||||
return string.IsNullOrWhiteSpace(ipAddress) ? "anonymous:unknown" : $"ip:{ipAddress}";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@@ -30,7 +30,6 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -13,11 +13,6 @@
|
|||||||
"http://localhost:3000"
|
"http://localhost:3000"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"RateLimiting": {
|
|
||||||
"PermitLimit": 600,
|
|
||||||
"WindowSeconds": 60,
|
|
||||||
"QueueLimit": 100
|
|
||||||
},
|
|
||||||
"Llm": {
|
"Llm": {
|
||||||
"BaseUrl": "https://api.openai.com/v1/",
|
"BaseUrl": "https://api.openai.com/v1/",
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
@@ -28,8 +23,7 @@
|
|||||||
},
|
},
|
||||||
"ModeusApi": {
|
"ModeusApi": {
|
||||||
"BaseUrl": "https://schedule.rdcenter.ru",
|
"BaseUrl": "https://schedule.rdcenter.ru",
|
||||||
"ApiKey": "",
|
"ApiKey": ""
|
||||||
"TimeoutSeconds": 180
|
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
|
|||||||
@@ -3789,146 +3789,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/users/me/enrollments/calendar-subscription": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
],
|
|
||||||
"description": "**Required:** any authenticated user",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CalendarSubscriptionDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Bearer": [ ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/users/calendar/enrollments/{token}.ics": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "token",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden",
|
|
||||||
"content": {
|
|
||||||
"text/calendar": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/users/me/enrollments.ics": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
],
|
|
||||||
"description": "**Required:** any authenticated user",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized — JWT token missing or invalid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Bearer": [ ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/users/me/enrollments/{lectureId}.ics": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
],
|
|
||||||
"description": "**Required:** any authenticated user",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "lectureId",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Not Found",
|
|
||||||
"content": {
|
|
||||||
"text/calendar": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized — JWT token missing or invalid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Bearer": [ ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/users/me/reviews": {
|
"/api/v1/users/me/reviews": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4298,52 +4158,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/users/admin/stats": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
],
|
|
||||||
"summary": "Получить статистику для админского дашборда.",
|
|
||||||
"description": "Только Admin.\n\n**Required roles:** Admin",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Агрегированная статистика дашборда.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/AdminDashboardStatsDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Требуется аутентификация.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Требуется роль Admin.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Bearer": [ ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/users/{id}/enrollments": {
|
"/api/v1/users/{id}/enrollments": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4944,28 +4758,6 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"AdminDashboardStatsDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"usersCount": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"lecturesCount": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"enrollmentsCount": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"pendingReviewsCount": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"AuthResponse": {
|
"AuthResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4983,16 +4775,6 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"CalendarSubscriptionDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"feedUrl": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"CoinTransactionDto": {
|
"CoinTransactionDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
|
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
|
|||||||
@@ -42,17 +42,8 @@ public record UserStatsDto(
|
|||||||
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
|
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
|
||||||
);
|
);
|
||||||
|
|
||||||
public record AdminDashboardStatsDto(
|
|
||||||
int UsersCount,
|
|
||||||
int LecturesCount,
|
|
||||||
int EnrollmentsCount,
|
|
||||||
int PendingReviewsCount
|
|
||||||
);
|
|
||||||
|
|
||||||
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
public record EnrollmentSlotRuleDto(int Level, int Slots);
|
||||||
|
|
||||||
public record CalendarSubscriptionDto(string FeedUrl);
|
|
||||||
|
|
||||||
public record UpdateUserRequest(
|
public record UpdateUserRequest(
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? AvatarUrl
|
string? AvatarUrl
|
||||||
|
|||||||
@@ -29,14 +29,11 @@ public class ModeusEvent
|
|||||||
public string? TypeId { get; init; }
|
public string? TypeId { get; init; }
|
||||||
public DateTime StartsAt { get; init; }
|
public DateTime StartsAt { get; init; }
|
||||||
public DateTime EndsAt { get; init; }
|
public DateTime EndsAt { get; init; }
|
||||||
public ModeusIctisStats? IctisStats { get; init; }
|
|
||||||
|
|
||||||
[JsonPropertyName("_links")]
|
[JsonPropertyName("_links")]
|
||||||
public ModeusEventLinks? Links { get; init; }
|
public ModeusEventLinks? Links { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
|
|
||||||
|
|
||||||
public class ModeusEventLinks
|
public class ModeusEventLinks
|
||||||
{
|
{
|
||||||
[JsonPropertyName("course-unit-realization")]
|
[JsonPropertyName("course-unit-realization")]
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ public interface IUserService
|
|||||||
Task<UserDto> GetByIdAsync(int id);
|
Task<UserDto> GetByIdAsync(int id);
|
||||||
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
|
||||||
Task<UserStatsDto> GetStatsAsync(int id);
|
Task<UserStatsDto> GetStatsAsync(int id);
|
||||||
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
|
|
||||||
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
|
Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination);
|
||||||
Task<string> GetMyEnrollmentsIcsAsync(int userId);
|
|
||||||
Task<string> GetEnrollmentIcsAsync(int userId, int lectureId);
|
|
||||||
Task<string> GetCalendarSubscriptionTokenAsync(int userId);
|
|
||||||
Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token);
|
|
||||||
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter);
|
||||||
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
Task SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
|
||||||
Task SetActiveAsync(int id, bool isActive);
|
Task SetActiveAsync(int id, bool isActive);
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ namespace UniVerse.Application.Mappings;
|
|||||||
|
|
||||||
public static class MappingExtensions
|
public static class MappingExtensions
|
||||||
{
|
{
|
||||||
private static int OccupiedSeatsCount(this Lecture lecture) =>
|
|
||||||
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
|
|
||||||
|
|
||||||
// --- User ---
|
// --- User ---
|
||||||
public static UserDto ToDto(this User user, int level) => new(
|
public static UserDto ToDto(this User user, int level) => new(
|
||||||
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
||||||
@@ -60,7 +57,7 @@ public static class MappingExtensions
|
|||||||
lecture.LocationId, lecture.Location?.Name,
|
lecture.LocationId, lecture.Location?.Name,
|
||||||
lecture.Title, lecture.Description, lecture.Format,
|
lecture.Title, lecture.Description, lecture.Format,
|
||||||
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
||||||
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
|
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
||||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,7 +67,7 @@ public static class MappingExtensions
|
|||||||
lecture.LocationId, lecture.Location?.Name,
|
lecture.LocationId, lecture.Location?.Name,
|
||||||
lecture.Title, lecture.Description, lecture.Format,
|
lecture.Title, lecture.Description, lecture.Format,
|
||||||
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
|
||||||
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
|
lecture.MaxEnrollments, lecture.Enrollments.Count,
|
||||||
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,37 +6,11 @@ public static class ReviewPromptTemplate
|
|||||||
public const string ReviewTextPlaceholder = "{reviewText}";
|
public const string ReviewTextPlaceholder = "{reviewText}";
|
||||||
|
|
||||||
public const string Default = """
|
public const string Default = """
|
||||||
Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю.
|
Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями:
|
||||||
|
- quality_score: число от 0 до 1, указывающее на качество отзыва;
|
||||||
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста:
|
- sentiment: «Положительный», «Нейтральный» или «Отрицательный»;
|
||||||
{
|
- tags: массив соответствующих тематических тегов;
|
||||||
"quality_score": 0.0,
|
- is_informative: логическое значение, указывающее, является ли отзыв информативным.
|
||||||
"sentiment": "Нейтральный",
|
|
||||||
"tags": [],
|
|
||||||
"is_informative": false
|
|
||||||
}
|
|
||||||
|
|
||||||
Правила оценки:
|
|
||||||
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
|
|
||||||
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
|
|
||||||
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
|
|
||||||
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
|
|
||||||
|
|
||||||
Базовые теги:
|
|
||||||
- "структура лекции"
|
|
||||||
- "понятность объяснения"
|
|
||||||
- "темп"
|
|
||||||
- "сложность"
|
|
||||||
- "практические примеры"
|
|
||||||
- "материалы"
|
|
||||||
- "актуальность темы"
|
|
||||||
- "вовлеченность"
|
|
||||||
- "организация"
|
|
||||||
- "технические проблемы"
|
|
||||||
- "польза для обучения"
|
|
||||||
- "неинформативный отзыв"
|
|
||||||
|
|
||||||
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
|
|
||||||
|
|
||||||
Контекст лекции: {lectureContext}
|
Контекст лекции: {lectureContext}
|
||||||
Текст отзыва: {reviewText}
|
Текст отзыва: {reviewText}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ public class Lecture
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public bool IsOpen { get; set; } = true;
|
public bool IsOpen { get; set; } = true;
|
||||||
public int MaxEnrollments { get; set; }
|
public int MaxEnrollments { get; set; }
|
||||||
public int MandatoryAttendeesCount { get; set; }
|
|
||||||
public string? ExternalId { get; set; }
|
public string? ExternalId { get; set; }
|
||||||
public string? OnlineUrl { get; set; }
|
public string? OnlineUrl { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
|
|||||||
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
|
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
|
||||||
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
|
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
|
||||||
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
|
builder.Property(l => l.MaxEnrollments).HasColumnName("max_enrollments").HasDefaultValue(0);
|
||||||
builder.Property(l => l.MandatoryAttendeesCount).HasColumnName("mandatory_attendees_count").HasDefaultValue(0);
|
|
||||||
builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255);
|
builder.Property(l => l.ExternalId).HasColumnName("external_id").HasMaxLength(255);
|
||||||
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
|
builder.Property(l => l.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
|
||||||
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
|
|||||||
@@ -42,11 +42,12 @@ public class ModeusApiClient : IModeusApiClient
|
|||||||
AddNonEmpty(body, "curriculumId", request.CurriculumId);
|
AddNonEmpty(body, "curriculumId", request.CurriculumId);
|
||||||
AddNonEmpty(body, "typeId", request.TypeId);
|
AddNonEmpty(body, "typeId", request.TypeId);
|
||||||
|
|
||||||
|
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
|
||||||
var requestJson = JsonSerializer.Serialize(body);
|
var requestJson = JsonSerializer.Serialize(body);
|
||||||
var requestSummary = $"POST /api/ictis?includeCounts=true. Request JSON: {requestJson}";
|
await EnsureSuccessAsync(response, "Modeus events search",
|
||||||
var response = await _http.PostAsJsonAsync("/api/ictis?includeCounts=true", body);
|
BuildEventsRequestSummary(requestJson));
|
||||||
await EnsureSuccessAsync(response, "ICTIS events search", requestSummary);
|
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
|
||||||
return await ReadJsonAsync<ModeusEventsResponse>(response, "ICTIS events search", requestSummary)
|
BuildEventsRequestSummary(requestJson))
|
||||||
?? new ModeusEventsResponse();
|
?? new ModeusEventsResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,8 @@ public class ModeusApiClient : IModeusApiClient
|
|||||||
response.StatusCode);
|
response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
|
||||||
|
|
||||||
private static void AddNonEmpty<T>(
|
private static void AddNonEmpty<T>(
|
||||||
IDictionary<string, object?> body,
|
IDictionary<string, object?> body,
|
||||||
string key,
|
string key,
|
||||||
|
|||||||
Generated
-1149
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace UniVerse.Infrastructure.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class MandatoryAttendeesCount : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "mandatory_attendees_count",
|
|
||||||
table: "lectures",
|
|
||||||
type: "integer",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "mandatory_attendees_count",
|
|
||||||
table: "lectures");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ namespace UniVerse.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "10.0.8")
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" });
|
||||||
@@ -250,12 +250,6 @@ namespace UniVerse.Infrastructure.Migrations
|
|||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("location_id");
|
.HasColumnName("location_id");
|
||||||
|
|
||||||
b.Property<int>("MandatoryAttendeesCount")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasDefaultValue(0)
|
|
||||||
.HasColumnName("mandatory_attendees_count");
|
|
||||||
|
|
||||||
b.Property<int>("MaxEnrollments")
|
b.Property<int>("MaxEnrollments")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
|
|||||||
@@ -122,8 +122,7 @@ public class LectureService : ILectureService
|
|||||||
.FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId);
|
.FirstOrDefaultAsync(l => l.Id == lectureId) ?? throw new NotFoundException("Lecture", lectureId);
|
||||||
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId);
|
var user = await _db.Users.FindAsync(userId) ?? throw new NotFoundException("User", userId);
|
||||||
if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment.");
|
if (!lecture.IsOpen) throw new ConflictException("Lecture is not open for enrollment.");
|
||||||
var occupiedSeatsCount = Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
|
if (lecture.MaxEnrollments > 0 && lecture.Enrollments.Count >= lecture.MaxEnrollments)
|
||||||
if (lecture.MaxEnrollments > 0 && occupiedSeatsCount >= lecture.MaxEnrollments)
|
|
||||||
throw new ConflictException("Lecture is full.");
|
throw new ConflictException("Lecture is full.");
|
||||||
if (lecture.Enrollments.Any(e => e.UserId == userId))
|
if (lecture.Enrollments.Any(e => e.UserId == userId))
|
||||||
throw new ConflictException("Already enrolled.");
|
throw new ConflictException("Already enrolled.");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text.Json;
|
||||||
using UniVerse.Application.DTOs.Sync;
|
using UniVerse.Application.DTOs.Sync;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Domain.Entities;
|
using UniVerse.Domain.Entities;
|
||||||
@@ -54,7 +55,6 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
|
var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0;
|
||||||
var mandatoryAttendeesCount = GetMandatoryAttendeesCount(ev.IctisStats);
|
|
||||||
var startsAt = EnsureUtc(ev.StartsAt);
|
var startsAt = EnsureUtc(ev.StartsAt);
|
||||||
var endsAt = EnsureUtc(ev.EndsAt);
|
var endsAt = EnsureUtc(ev.EndsAt);
|
||||||
|
|
||||||
@@ -68,7 +68,6 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
existing.LocationId = location?.Id;
|
existing.LocationId = location?.Id;
|
||||||
existing.TeacherId = teacher?.Id;
|
existing.TeacherId = teacher?.Id;
|
||||||
existing.MaxEnrollments = lectureCapacity;
|
existing.MaxEnrollments = lectureCapacity;
|
||||||
existing.MandatoryAttendeesCount = mandatoryAttendeesCount;
|
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
@@ -92,8 +91,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
ExternalId = ev.Id,
|
ExternalId = ev.Id,
|
||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
MaxEnrollments = lectureCapacity,
|
MaxEnrollments = lectureCapacity
|
||||||
MandatoryAttendeesCount = mandatoryAttendeesCount
|
|
||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
@@ -113,7 +111,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
updated,
|
updated,
|
||||||
skipped,
|
skipped,
|
||||||
[
|
[
|
||||||
"endpoint=POST /api/ictis?includeCounts=true",
|
$"requestJson={BuildScheduleRequestJson(request)}",
|
||||||
$"timeMin={request.TimeMin:O}",
|
$"timeMin={request.TimeMin:O}",
|
||||||
$"timeMax={request.TimeMax:O}"
|
$"timeMax={request.TimeMax:O}"
|
||||||
]));
|
]));
|
||||||
@@ -445,9 +443,6 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
private static int? NormalizeCapacity(int? capacity) =>
|
private static int? NormalizeCapacity(int? capacity) =>
|
||||||
capacity is > 0 ? capacity : null;
|
capacity is > 0 ? capacity : null;
|
||||||
|
|
||||||
private static int GetMandatoryAttendeesCount(ModeusIctisStats? stats) =>
|
|
||||||
Math.Max(0, stats?.StudentCount ?? 0) + Math.Max(0, stats?.TeacherCount ?? 0);
|
|
||||||
|
|
||||||
private static string BuildModeusTeacherEmail(string personId) =>
|
private static string BuildModeusTeacherEmail(string personId) =>
|
||||||
$"modeus-{personId}@modeus.local".ToLowerInvariant();
|
$"modeus-{personId}@modeus.local".ToLowerInvariant();
|
||||||
|
|
||||||
@@ -493,6 +488,37 @@ public class ScheduleSyncService : IScheduleSyncService
|
|||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildScheduleRequestJson(SyncScheduleRequest request)
|
||||||
|
{
|
||||||
|
var body = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["size"] = request.Size is > 0 ? request.Size.Value : 900,
|
||||||
|
["timeMin"] = request.TimeMin,
|
||||||
|
["timeMax"] = request.TimeMax
|
||||||
|
};
|
||||||
|
|
||||||
|
AddNonEmpty(body, "roomId", request.RoomId);
|
||||||
|
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
|
||||||
|
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
|
||||||
|
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
|
||||||
|
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
|
||||||
|
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
|
||||||
|
AddNonEmpty(body, "profileName", request.ProfileName);
|
||||||
|
AddNonEmpty(body, "curriculumId", request.CurriculumId);
|
||||||
|
AddNonEmpty(body, "typeId", request.TypeId);
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddNonEmpty<T>(
|
||||||
|
IDictionary<string, object?> body,
|
||||||
|
string key,
|
||||||
|
IReadOnlyList<T>? values)
|
||||||
|
{
|
||||||
|
if (values is { Count: > 0 })
|
||||||
|
body[key] = values;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? GetHrefId(string? href)
|
private static string? GetHrefId(string? href)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(href))
|
if (string.IsNullOrWhiteSpace(href))
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
using System.Buffers.Binary;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using Ical.Net;
|
|
||||||
using Ical.Net.CalendarComponents;
|
|
||||||
using Ical.Net.DataTypes;
|
|
||||||
using Ical.Net.Serialization;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using UniVerse.Application.DTOs.Common;
|
using UniVerse.Application.DTOs.Common;
|
||||||
using UniVerse.Application.DTOs.Lectures;
|
using UniVerse.Application.DTOs.Lectures;
|
||||||
using UniVerse.Application.DTOs.Users;
|
using UniVerse.Application.DTOs.Users;
|
||||||
@@ -21,20 +13,13 @@ namespace UniVerse.Infrastructure.Services;
|
|||||||
|
|
||||||
public class UserService : IUserService
|
public class UserService : IUserService
|
||||||
{
|
{
|
||||||
private const byte CalendarTokenVersion = 1;
|
|
||||||
private const int CalendarTokenPayloadLength = 5;
|
|
||||||
private const int CalendarTokenSignatureLength = 32;
|
|
||||||
private const string CalendarTokenKeyContext = "universe-calendar-subscription-v1";
|
|
||||||
|
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IGamificationService _gamification;
|
private readonly IGamificationService _gamification;
|
||||||
private readonly IConfiguration _config;
|
|
||||||
|
|
||||||
public UserService(AppDbContext db, IGamificationService gamification, IConfiguration config)
|
public UserService(AppDbContext db, IGamificationService gamification)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_gamification = gamification;
|
_gamification = gamification;
|
||||||
_config = config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserDto> GetByIdAsync(int id)
|
public async Task<UserDto> GetByIdAsync(int id)
|
||||||
@@ -89,17 +74,6 @@ public class UserService : IUserService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync()
|
|
||||||
{
|
|
||||||
var usersCount = await _db.Users
|
|
||||||
.CountAsync(user => !user.Roles.Any(role => role.Role == UserRole.Teacher));
|
|
||||||
var lecturesCount = await _db.Lectures.CountAsync();
|
|
||||||
var enrollmentsCount = await _db.LectureEnrollments.CountAsync();
|
|
||||||
var pendingReviewsCount = await _db.Reviews.CountAsync(review => review.LlmStatus == ReviewLlmStatus.Pending);
|
|
||||||
|
|
||||||
return new AdminDashboardStatsDto(usersCount, lecturesCount, enrollmentsCount, pendingReviewsCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
|
public async Task<PagedResult<LectureDto>> GetEnrollmentsAsync(int id, PaginationRequest pagination)
|
||||||
{
|
{
|
||||||
if (!await _db.Users.AnyAsync(u => u.Id == id))
|
if (!await _db.Users.AnyAsync(u => u.Id == id))
|
||||||
@@ -130,154 +104,6 @@ public class UserService : IUserService
|
|||||||
pagination.PageSize);
|
pagination.PageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<string> GetMyEnrollmentsIcsAsync(int userId)
|
|
||||||
{
|
|
||||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
|
||||||
throw new NotFoundException("User", userId);
|
|
||||||
|
|
||||||
var lectures = await _db.LectureEnrollments
|
|
||||||
.Where(e => e.UserId == userId)
|
|
||||||
.Include(e => e.Lecture)
|
|
||||||
.ThenInclude(l => l.Teacher)
|
|
||||||
.Include(e => e.Lecture)
|
|
||||||
.ThenInclude(l => l.Location)
|
|
||||||
.OrderBy(e => e.Lecture.StartsAt)
|
|
||||||
.Select(e => e.Lecture)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return BuildIcs(lectures, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GetEnrollmentIcsAsync(int userId, int lectureId)
|
|
||||||
{
|
|
||||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
|
||||||
throw new NotFoundException("User", userId);
|
|
||||||
|
|
||||||
var lecture = await _db.Lectures
|
|
||||||
.Include(l => l.Teacher)
|
|
||||||
.Include(l => l.Location)
|
|
||||||
.FirstOrDefaultAsync(l => l.Id == lectureId)
|
|
||||||
?? throw new NotFoundException("Lecture", lectureId);
|
|
||||||
|
|
||||||
return BuildIcs([lecture], userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GetCalendarSubscriptionTokenAsync(int userId)
|
|
||||||
{
|
|
||||||
if (!await _db.Users.AnyAsync(u => u.Id == userId))
|
|
||||||
throw new NotFoundException("User", userId);
|
|
||||||
|
|
||||||
Span<byte> payload = stackalloc byte[CalendarTokenPayloadLength];
|
|
||||||
payload[0] = CalendarTokenVersion;
|
|
||||||
BinaryPrimitives.WriteInt32BigEndian(payload[1..], userId);
|
|
||||||
|
|
||||||
var signature = SignCalendarTokenPayload(payload);
|
|
||||||
var tokenBytes = new byte[CalendarTokenPayloadLength + CalendarTokenSignatureLength];
|
|
||||||
payload.CopyTo(tokenBytes);
|
|
||||||
signature.CopyTo(tokenBytes.AsSpan(CalendarTokenPayloadLength));
|
|
||||||
|
|
||||||
return ToBase64Url(tokenBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GetEnrollmentsIcsBySubscriptionTokenAsync(string token)
|
|
||||||
{
|
|
||||||
var userId = ValidateCalendarSubscriptionToken(token);
|
|
||||||
return await GetMyEnrollmentsIcsAsync(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int ValidateCalendarSubscriptionToken(string token)
|
|
||||||
{
|
|
||||||
var tokenBytes = FromBase64Url(token);
|
|
||||||
if (tokenBytes.Length != CalendarTokenPayloadLength + CalendarTokenSignatureLength)
|
|
||||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
|
||||||
|
|
||||||
var payload = tokenBytes.AsSpan(0, CalendarTokenPayloadLength);
|
|
||||||
var signature = tokenBytes.AsSpan(CalendarTokenPayloadLength, CalendarTokenSignatureLength);
|
|
||||||
if (payload[0] != CalendarTokenVersion)
|
|
||||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
|
||||||
|
|
||||||
var expectedSignature = SignCalendarTokenPayload(payload);
|
|
||||||
if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature))
|
|
||||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
|
||||||
|
|
||||||
var userId = BinaryPrimitives.ReadInt32BigEndian(payload[1..]);
|
|
||||||
if (userId <= 0)
|
|
||||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
|
||||||
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] SignCalendarTokenPayload(ReadOnlySpan<byte> payload)
|
|
||||||
{
|
|
||||||
var calendarKey = DeriveCalendarTokenKey();
|
|
||||||
return HMACSHA256.HashData(calendarKey, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] DeriveCalendarTokenKey()
|
|
||||||
{
|
|
||||||
var jwtSecret = _config["Jwt:Secret"];
|
|
||||||
if (string.IsNullOrWhiteSpace(jwtSecret))
|
|
||||||
throw new InvalidOperationException("Jwt:Secret is not configured.");
|
|
||||||
|
|
||||||
return HMACSHA256.HashData(
|
|
||||||
Encoding.UTF8.GetBytes(jwtSecret),
|
|
||||||
Encoding.UTF8.GetBytes(CalendarTokenKeyContext));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ToBase64Url(ReadOnlySpan<byte> bytes) =>
|
|
||||||
Convert.ToBase64String(bytes)
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
|
|
||||||
private static byte[] FromBase64Url(string value)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
|
||||||
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
|
|
||||||
return Convert.FromBase64String(padded);
|
|
||||||
}
|
|
||||||
catch (FormatException)
|
|
||||||
{
|
|
||||||
throw new ForbiddenException("Invalid calendar subscription token.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildIcs(List<Domain.Entities.Lecture> lectures, int userId)
|
|
||||||
{
|
|
||||||
var calendar = new Calendar
|
|
||||||
{
|
|
||||||
Method = "PUBLISH",
|
|
||||||
ProductId = "-//UniVerse//Lectures Calendar//EN"
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var lecture in lectures)
|
|
||||||
{
|
|
||||||
var location = lecture.Location is null
|
|
||||||
? string.Empty
|
|
||||||
: $"{lecture.Location.Building}{(string.IsNullOrWhiteSpace(lecture.Location.Room) ? string.Empty : $", ауд. {lecture.Location.Room}")}";
|
|
||||||
|
|
||||||
var teacherName = lecture.Teacher?.DisplayName
|
|
||||||
?? lecture.Teacher?.Email
|
|
||||||
?? "не указан";
|
|
||||||
|
|
||||||
calendar.Events.Add(new CalendarEvent
|
|
||||||
{
|
|
||||||
Uid = $"lecture-{lecture.Id}-user-{userId}@universe.local",
|
|
||||||
Summary = lecture.Title,
|
|
||||||
Description = $"{lecture.Description}\nПреподаватель: {teacherName}",
|
|
||||||
Location = location,
|
|
||||||
DtStart = new CalDateTime(DateTime.SpecifyKind(lecture.StartsAt, DateTimeKind.Utc)),
|
|
||||||
DtEnd = new CalDateTime(DateTime.SpecifyKind(lecture.EndsAt, DateTimeKind.Utc)),
|
|
||||||
DtStamp = new CalDateTime(DateTime.UtcNow)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CalendarSerializer().SerializeToString(calendar) ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter)
|
public async Task<PagedResult<UserDto>> GetAllAsync(UserFilterRequest filter)
|
||||||
{
|
{
|
||||||
var query = _db.Users.AsQueryable();
|
var query = _db.Users.AsQueryable();
|
||||||
|
|||||||
@@ -9,11 +9,10 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.84.0" />
|
<PackageReference Include="Microsoft.Identity.Client" Version="4.84.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||||
<PackageReference Include="Quartz" Version="3.18.1" />
|
<PackageReference Include="Quartz" Version="3.18.1" />
|
||||||
<PackageReference Include="Ical.Net" Version="5.2.2" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ services:
|
|||||||
|
|
||||||
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
- Cors:Origins=${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
||||||
|
|
||||||
- RateLimiting:PermitLimit=${RATE_LIMITING_PERMIT_LIMIT:-600}
|
|
||||||
- RateLimiting:WindowSeconds=${RATE_LIMITING_WINDOW_SECONDS:-60}
|
|
||||||
- RateLimiting:QueueLimit=${RATE_LIMITING_QUEUE_LIMIT:-100}
|
|
||||||
|
|
||||||
- Llm:BaseUrl=${LLM_BASE_URL}
|
- Llm:BaseUrl=${LLM_BASE_URL}
|
||||||
- Llm:ApiKey=${LLM_API_KEY}
|
- Llm:ApiKey=${LLM_API_KEY}
|
||||||
- Llm:Model=${LLM_MODEL}
|
- Llm:Model=${LLM_MODEL}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Отчет по нагрузочному тестированию 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-порога. Это стоит учитывать при дальнейшем анализе хвостовых задержек.
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Базовый нагрузочный тест (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="<jwt>"
|
|
||||||
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.»
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# 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` считаются временными артефактами тестовых прогонов.
|
|
||||||
@@ -35,10 +35,5 @@ coverage
|
|||||||
# Vitest
|
# Vitest
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
# Playwright
|
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/blob-report/
|
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
*.timestamp-*-*.mjs
|
*.timestamp-*-*.mjs
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import pluginOxlint from 'eslint-plugin-oxlint'
|
|||||||
import skipFormatting from 'eslint-config-prettier/flat'
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
import vueScopedCss from 'eslint-plugin-vue-scoped-css'
|
import vueScopedCss from 'eslint-plugin-vue-scoped-css'
|
||||||
|
|
||||||
type VueTsConfig = Parameters<typeof defineConfigWithVueTs>[number]
|
|
||||||
|
|
||||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
@@ -22,7 +20,7 @@ export default defineConfigWithVueTs(
|
|||||||
|
|
||||||
...pluginVue.configs['flat/essential'],
|
...pluginVue.configs['flat/essential'],
|
||||||
vueTsConfigs.recommended,
|
vueTsConfigs.recommended,
|
||||||
...(vueScopedCss.configs.recommended as VueTsConfig[]),
|
...vueScopedCss.configs.recommended,
|
||||||
|
|
||||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,7 @@
|
|||||||
"lint": "run-s lint:*",
|
"lint": "run-s lint:*",
|
||||||
"lint:oxlint": "oxlint . --fix",
|
"lint:oxlint": "oxlint . --fix",
|
||||||
"lint:eslint": "eslint . --fix --cache",
|
"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": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@@ -32,7 +28,7 @@
|
|||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.70.0",
|
"eslint-plugin-oxlint": "~1.60.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"eslint-plugin-vue-scoped-css": "^3.1.0",
|
"eslint-plugin-vue-scoped-css": "^3.1.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
@@ -42,8 +38,7 @@
|
|||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
"vite-plugin-vue-devtools": "^8.1.1",
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
"vue-tsc": "^3.2.6",
|
"vue-tsc": "^3.2.6"
|
||||||
"@playwright/test": "^1.55.1"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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' } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
Generated
+4
-42
@@ -18,9 +18,6 @@ importers:
|
|||||||
specifier: ^5.0.6
|
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))
|
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:
|
devDependencies:
|
||||||
'@playwright/test':
|
|
||||||
specifier: ^1.55.1
|
|
||||||
version: 1.60.0
|
|
||||||
'@tsconfig/node24':
|
'@tsconfig/node24':
|
||||||
specifier: ^24.0.4
|
specifier: ^24.0.4
|
||||||
version: 24.0.4
|
version: 24.0.4
|
||||||
@@ -436,11 +433,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@playwright/test@1.60.0':
|
|
||||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
@@ -787,8 +779,8 @@ packages:
|
|||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
brace-expansion@5.0.6:
|
brace-expansion@5.0.5:
|
||||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
@@ -1016,11 +1008,6 @@ packages:
|
|||||||
flatted@3.4.2:
|
flatted@3.4.2:
|
||||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1360,16 +1347,6 @@ packages:
|
|||||||
pkg-types@2.3.1:
|
pkg-types@2.3.1:
|
||||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||||
|
|
||||||
playwright-core@1.60.0:
|
|
||||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
playwright@1.60.0:
|
|
||||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
postcss-safe-parser@7.0.1:
|
postcss-safe-parser@7.0.1:
|
||||||
resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==}
|
resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==}
|
||||||
engines: {node: '>=18.0'}
|
engines: {node: '>=18.0'}
|
||||||
@@ -2034,10 +2011,6 @@ snapshots:
|
|||||||
'@oxlint/binding-win32-x64-msvc@1.60.0':
|
'@oxlint/binding-win32-x64-msvc@1.60.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@playwright/test@1.60.0':
|
|
||||||
dependencies:
|
|
||||||
playwright: 1.60.0
|
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
||||||
@@ -2414,7 +2387,7 @@ snapshots:
|
|||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
brace-expansion@5.0.6:
|
brace-expansion@5.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
@@ -2633,9 +2606,6 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.4.2: {}
|
flatted@3.4.2: {}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2792,7 +2762,7 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@10.2.5:
|
minimatch@10.2.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.6
|
brace-expansion: 5.0.5
|
||||||
|
|
||||||
mitt@3.0.1: {}
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
@@ -2919,14 +2889,6 @@ snapshots:
|
|||||||
exsolve: 1.0.8
|
exsolve: 1.0.8
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
playwright-core@1.60.0: {}
|
|
||||||
|
|
||||||
playwright@1.60.0:
|
|
||||||
dependencies:
|
|
||||||
playwright-core: 1.60.0
|
|
||||||
optionalDependencies:
|
|
||||||
fsevents: 2.3.2
|
|
||||||
|
|
||||||
postcss-safe-parser@7.0.1(postcss@8.5.14):
|
postcss-safe-parser@7.0.1(postcss@8.5.14):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.14
|
postcss: 8.5.14
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
@@ -81,30 +81,3 @@ export function extractItems<T>(payload: T[] | { items?: T[] } | undefined): T[]
|
|||||||
if (Array.isArray(payload)) return payload
|
if (Array.isArray(payload)) return payload
|
||||||
return payload?.items ?? []
|
return payload?.items ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function apiRequestBlob(
|
|
||||||
path: string,
|
|
||||||
options: RequestInit & { query?: Record<string, unknown> } = {},
|
|
||||||
): Promise<Blob> {
|
|
||||||
const headers = new Headers(options.headers)
|
|
||||||
if (!headers.has('Accept')) headers.set('Accept', 'text/calendar')
|
|
||||||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
|
|
||||||
const response = await fetch(makeUrl(path, options.query), {
|
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await parseResponse(response)
|
|
||||||
const message =
|
|
||||||
typeof body === 'object' && body && 'message' in body
|
|
||||||
? String((body as { message: unknown }).message)
|
|
||||||
: `API request failed with status ${response.status}`
|
|
||||||
throw new ApiError(message, response.status, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.blob()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiRequest, apiRequestBlob, extractItems } from './client'
|
import { apiRequest, extractItems } from './client'
|
||||||
import type {
|
import type {
|
||||||
AchievementDto,
|
AchievementDto,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
@@ -18,8 +18,6 @@ import type {
|
|||||||
TagDto,
|
TagDto,
|
||||||
UpdateReviewPromptRequest,
|
UpdateReviewPromptRequest,
|
||||||
UserAchievementDto,
|
UserAchievementDto,
|
||||||
AdminDashboardStatsDto,
|
|
||||||
CalendarSubscriptionDto,
|
|
||||||
CurrentUserDto,
|
CurrentUserDto,
|
||||||
UserDto,
|
UserDto,
|
||||||
UserQuery,
|
UserQuery,
|
||||||
@@ -70,18 +68,12 @@ export const usersApi = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
|
myStats: () => apiRequest<UserStatsDto>('/users/me/stats'),
|
||||||
adminStats: () => apiRequest<AdminDashboardStatsDto>('/users/admin/stats'),
|
|
||||||
async myEnrollments() {
|
async myEnrollments() {
|
||||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
|
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
|
||||||
'/users/me/enrollments',
|
'/users/me/enrollments',
|
||||||
)
|
)
|
||||||
return extractItems(payload)
|
return extractItems(payload)
|
||||||
},
|
},
|
||||||
downloadMyEnrollmentsIcs: () => apiRequestBlob('/users/me/enrollments.ics'),
|
|
||||||
downloadEnrollmentIcs: (lectureId: string | number) =>
|
|
||||||
apiRequestBlob(`/users/me/enrollments/${lectureId}.ics`),
|
|
||||||
getCalendarSubscription: () =>
|
|
||||||
apiRequest<CalendarSubscriptionDto>('/users/me/enrollments/calendar-subscription'),
|
|
||||||
async myAchievements() {
|
async myAchievements() {
|
||||||
const payload = await apiRequest<
|
const payload = await apiRequest<
|
||||||
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
|
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
|
||||||
|
|||||||
@@ -76,17 +76,6 @@ export interface UserStatsDto {
|
|||||||
enrollmentSlotRules: EnrollmentSlotRuleDto[]
|
enrollmentSlotRules: EnrollmentSlotRuleDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminDashboardStatsDto {
|
|
||||||
usersCount: number
|
|
||||||
lecturesCount: number
|
|
||||||
enrollmentsCount: number
|
|
||||||
pendingReviewsCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalendarSubscriptionDto {
|
|
||||||
feedUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnrollmentSlotRuleDto {
|
export interface EnrollmentSlotRuleDto {
|
||||||
level: number
|
level: number
|
||||||
slots: number
|
slots: number
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
<script setup lang="ts" generic="TRow extends object">
|
<script setup lang="ts">
|
||||||
type Column = { key: string; label: string; align?: 'left' | 'center' | 'right' }
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
columns: Column[]
|
columns: Array<{ key: string; label: string; align?: 'left' | 'center' | 'right' | string }>
|
||||||
rows: TRow[]
|
rows: Record<string, unknown>[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineSlots<Record<string, (props: { row: TRow; value: unknown }) => unknown>>()
|
|
||||||
|
|
||||||
function getCell(row: TRow, key: string): unknown {
|
|
||||||
return (row as Record<string, unknown>)[key]
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,8 +18,8 @@ function getCell(row: TRow, key: string): unknown {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, i) in rows" :key="i">
|
<tr v-for="(row, i) in rows" :key="i">
|
||||||
<td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
|
<td v-for="col in columns" :key="col.key" :class="`align-${col.align ?? 'left'}`">
|
||||||
<slot :name="col.key" :row="row" :value="getCell(row, col.key)">
|
<slot :name="col.key" :row="row" :value="row[col.key]">
|
||||||
{{ getCell(row, col.key) }}
|
{{ row[col.key] }}
|
||||||
</slot>
|
</slot>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export function downloadFile(blob: Blob, fileName: string) {
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = fileName
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,17 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { syncApi, usersApi } from '@/api'
|
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
|
||||||
import type { AdminDashboardStatsDto, SyncStatusDto } from '@/api/types'
|
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
|
||||||
|
|
||||||
const stats = ref<AdminDashboardStatsDto | null>(null)
|
const users = ref<UserDto[]>([])
|
||||||
|
const lectures = ref<LectureDto[]>([])
|
||||||
|
const pendingReviewsCount = ref(0)
|
||||||
const syncStatus = ref<SyncStatusDto | null>(null)
|
const syncStatus = ref<SyncStatusDto | null>(null)
|
||||||
|
|
||||||
|
const enrollmentCount = computed(() =>
|
||||||
|
lectures.value.reduce((sum, lecture) => sum + lecture.enrollmentsCount, 0),
|
||||||
|
)
|
||||||
const syncMeta = computed(() =>
|
const syncMeta = computed(() =>
|
||||||
syncStatus.value?.lastSyncAt
|
syncStatus.value?.lastSyncAt
|
||||||
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
|
? `Последняя синхронизация: ${new Date(syncStatus.value.lastSyncAt).toLocaleString('ru-RU')}`
|
||||||
@@ -15,8 +21,16 @@ const syncMeta = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [statsResult, syncResult] = await Promise.allSettled([usersApi.adminStats(), syncApi.status()])
|
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
|
||||||
if (statsResult.status === 'fulfilled') stats.value = statsResult.value
|
usersApi.list({ PageSize: 100 }),
|
||||||
|
lecturesApi.list({ PageSize: 100 }),
|
||||||
|
reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }),
|
||||||
|
syncApi.status(),
|
||||||
|
])
|
||||||
|
if (usersResult.status === 'fulfilled') users.value = usersResult.value
|
||||||
|
if (lecturesResult.status === 'fulfilled') lectures.value = lecturesResult.value
|
||||||
|
if (reviewsResult.status === 'fulfilled')
|
||||||
|
pendingReviewsCount.value = reviewsResult.value.totalCount
|
||||||
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
|
if (syncResult.status === 'fulfilled') syncStatus.value = syncResult.value
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -26,17 +40,12 @@ onMounted(async () => {
|
|||||||
<h1 class="page-title">Дашборд администратора</h1>
|
<h1 class="page-title">Дашборд администратора</h1>
|
||||||
|
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatsWidget label="Пользователей" :value="stats?.usersCount ?? 0" icon="users" color="green" />
|
<StatsWidget label="Пользователей" :value="users.length" icon="users" color="green" />
|
||||||
<StatsWidget label="Лекций" :value="stats?.lecturesCount ?? 0" icon="books" color="aqua" />
|
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
|
||||||
<StatsWidget
|
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
|
||||||
label="Записей"
|
|
||||||
:value="stats?.enrollmentsCount ?? 0"
|
|
||||||
icon="calendar-event"
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
<StatsWidget
|
<StatsWidget
|
||||||
label="Отзывы на проверке"
|
label="Отзывы на проверке"
|
||||||
:value="stats?.pendingReviewsCount ?? 0"
|
:value="pendingReviewsCount"
|
||||||
icon="message-circle"
|
icon="message-circle"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ import EmptyState from '@/components/ui/EmptyState.vue'
|
|||||||
import CreateLectureModal from '@/components/admin/CreateLectureModal.vue'
|
import CreateLectureModal from '@/components/admin/CreateLectureModal.vue'
|
||||||
|
|
||||||
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
|
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
|
||||||
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
|
|
||||||
type TabConfig = {
|
type TabConfig = {
|
||||||
title: string
|
title: string
|
||||||
columns: DataTableColumn[]
|
columns: Array<{ key: string; label: string; align?: string }>
|
||||||
rows: Record<string, unknown>[]
|
rows: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +161,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = computed<TabConfig>(() => {
|
const current = computed(() => {
|
||||||
const config = tabConfig[activeTab.value]
|
const config = tabConfig[activeTab.value]
|
||||||
if (activeTab.value === 'lectures') {
|
if (activeTab.value === 'lectures') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import EmptyState from '@/components/ui/EmptyState.vue'
|
|||||||
import { reviewsApi } from '@/api'
|
import { reviewsApi } from '@/api'
|
||||||
import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types'
|
import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types'
|
||||||
|
|
||||||
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
|
const columns = [
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
|
||||||
{ key: 'id', label: 'ID' },
|
{ key: 'id', label: 'ID' },
|
||||||
{ key: 'lecture', label: 'Лекция' },
|
{ key: 'lecture', label: 'Лекция' },
|
||||||
{ key: 'student', label: 'Студент' },
|
{ key: 'student', label: 'Студент' },
|
||||||
@@ -297,8 +295,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable :columns="columns" :rows="rows">
|
<DataTable :columns="columns" :rows="rows">
|
||||||
<template #text="{ row }">
|
<template #text="{ value }">
|
||||||
<span class="review-text" :title="row.text">{{ row.text }}</span>
|
<span class="review-text" :title="value">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #analysis="{ row }">
|
<template #analysis="{ row }">
|
||||||
<div class="analysis-cell">
|
<div class="analysis-cell">
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ const users = ref<UserDto[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
|
const columns = [
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
|
||||||
{ key: 'name', label: 'Имя' },
|
{ key: 'name', label: 'Имя' },
|
||||||
{ key: 'email', label: 'Email' },
|
{ key: 'email', label: 'Email' },
|
||||||
{ key: 'role', label: 'Роль', align: 'center' },
|
{ key: 'role', label: 'Роль', align: 'center' },
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import ModalDialog from '@/components/ui/ModalDialog.vue'
|
|||||||
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
import EnrollmentLimitModal from '@/components/ui/EnrollmentLimitModal.vue'
|
||||||
|
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
|
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const viewMode = ref<'cards' | 'list' | 'calendar'>('cards')
|
const viewMode = ref<'cards' | 'list' | 'calendar'>('cards')
|
||||||
const dateFilter = ref('Любая дата')
|
const dateFilter = ref('Любая дата')
|
||||||
@@ -104,7 +102,7 @@ const appliedFilters = computed(() => {
|
|||||||
return filters
|
return filters
|
||||||
})
|
})
|
||||||
|
|
||||||
const tableColumns: DataTableColumn[] = [
|
const tableColumns = [
|
||||||
{ key: 'title', label: 'Лекция' },
|
{ key: 'title', label: 'Лекция' },
|
||||||
{ key: 'teacher', label: 'Преподаватель' },
|
{ key: 'teacher', label: 'Преподаватель' },
|
||||||
{ key: 'date', label: 'Дата' },
|
{ key: 'date', label: 'Дата' },
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import { computed, inject, onMounted, ref } from 'vue'
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { usersApi } from '@/api'
|
|
||||||
import { downloadFile } from '@/utils/downloadFile'
|
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
@@ -68,16 +66,6 @@ const levelProgressText = computed(() =>
|
|||||||
: `${userXp.value} XP`,
|
: `${userXp.value} XP`,
|
||||||
)
|
)
|
||||||
|
|
||||||
async function downloadLectureIcs(id: string) {
|
|
||||||
try {
|
|
||||||
const blob = await usersApi.downloadEnrollmentIcs(id)
|
|
||||||
downloadFile(blob, `lecture-${id}.ics`)
|
|
||||||
addToast?.("Файл календаря скачан", "success")
|
|
||||||
} catch (err) {
|
|
||||||
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
|
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
|
||||||
@@ -137,7 +125,7 @@ async function registerLecture(id: string) {
|
|||||||
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
|
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
|
||||||
Открыть
|
Открыть
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-secondary" @click="downloadLectureIcs(nextLecture.id)">Скачать .ics</button>
|
<button class="btn-secondary">Добавить в календарь</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { usersApi } from '@/api'
|
|
||||||
import { downloadFile } from '@/utils/downloadFile'
|
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
@@ -39,16 +37,6 @@ onMounted(async () => {
|
|||||||
await lecturesStore.fetchLecture(lectureId.value)
|
await lecturesStore.fetchLecture(lectureId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function downloadLectureIcs(id: string) {
|
|
||||||
try {
|
|
||||||
const blob = await usersApi.downloadEnrollmentIcs(id)
|
|
||||||
downloadFile(blob, `lecture-${id}.ics`)
|
|
||||||
addToast?.("Файл календаря скачан", "success")
|
|
||||||
} catch (err) {
|
|
||||||
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerLecture() {
|
async function registerLecture() {
|
||||||
if (!lecture.value) return
|
if (!lecture.value) return
|
||||||
if (slotRegistrationDisabled.value) {
|
if (slotRegistrationDisabled.value) {
|
||||||
@@ -102,7 +90,7 @@ async function registerLecture() {
|
|||||||
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
|
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
|
||||||
Отменить запись
|
Отменить запись
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-secondary" @click="downloadLectureIcs(lecture.id)">Скачать .ics</button>
|
<button class="btn-secondary">Добавить в календарь</button>
|
||||||
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
|
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
|
||||||
Оставить отзыв
|
Оставить отзыв
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { usersApi } from '@/api'
|
|
||||||
import { downloadFile } from '@/utils/downloadFile'
|
|
||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
@@ -16,11 +14,6 @@ const router = useRouter()
|
|||||||
const activeTab = ref<'upcoming' | 'history'>('upcoming')
|
const activeTab = ref<'upcoming' | 'history'>('upcoming')
|
||||||
const cancelModal = ref(false)
|
const cancelModal = ref(false)
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const calendarSubscriptionUrl = ref<string | null>(null)
|
|
||||||
const calendarActionPending = ref(false)
|
|
||||||
const addToast = inject('addToast') as
|
|
||||||
| ((message: string, type?: 'success' | 'error' | 'info') => void)
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
const upcoming = computed(() =>
|
const upcoming = computed(() =>
|
||||||
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
|
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
|
||||||
@@ -33,98 +26,6 @@ onMounted(async () => {
|
|||||||
if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser()
|
if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function downloadLectureIcs(id: string) {
|
|
||||||
try {
|
|
||||||
const blob = await usersApi.downloadEnrollmentIcs(id)
|
|
||||||
downloadFile(blob, `lecture-${id}.ics`)
|
|
||||||
} catch (err) {
|
|
||||||
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAllIcs() {
|
|
||||||
try {
|
|
||||||
const blob = await usersApi.downloadMyEnrollmentsIcs()
|
|
||||||
downloadFile(blob, 'my-lectures.ics')
|
|
||||||
} catch (err) {
|
|
||||||
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCalendarSubscriptionUrl() {
|
|
||||||
if (calendarSubscriptionUrl.value) return calendarSubscriptionUrl.value
|
|
||||||
const subscription = await usersApi.getCalendarSubscription()
|
|
||||||
calendarSubscriptionUrl.value = subscription.feedUrl
|
|
||||||
return subscription.feedUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyText(value: string) {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
// Browser may block async clipboard writes after awaiting the subscription request.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea')
|
|
||||||
textarea.value = value
|
|
||||||
textarea.style.position = 'fixed'
|
|
||||||
textarea.style.left = '-9999px'
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.focus()
|
|
||||||
textarea.select()
|
|
||||||
const copied = document.execCommand('copy')
|
|
||||||
textarea.remove()
|
|
||||||
return copied
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyCalendarSubscriptionUrl() {
|
|
||||||
try {
|
|
||||||
calendarActionPending.value = true
|
|
||||||
const feedUrl = await getCalendarSubscriptionUrl()
|
|
||||||
if (!await copyText(feedUrl)) throw new Error('Браузер заблокировал копирование ссылки')
|
|
||||||
addToast?.('ICS-ссылка скопирована', 'success')
|
|
||||||
} catch (err) {
|
|
||||||
addToast?.(err instanceof Error ? err.message : 'Не удалось скопировать ссылку', 'error')
|
|
||||||
} finally {
|
|
||||||
calendarActionPending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncWithGoogleCalendar() {
|
|
||||||
const googleWindow = window.open('about:blank', '_blank')
|
|
||||||
|
|
||||||
try {
|
|
||||||
calendarActionPending.value = true
|
|
||||||
const feedUrl = await getCalendarSubscriptionUrl()
|
|
||||||
const copied = await copyText(feedUrl)
|
|
||||||
|
|
||||||
const googleUrl = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(feedUrl)}`
|
|
||||||
if (googleWindow) {
|
|
||||||
googleWindow.opener = null
|
|
||||||
googleWindow.location.href = googleUrl
|
|
||||||
} else {
|
|
||||||
window.open(googleUrl, '_blank', 'noopener,noreferrer')
|
|
||||||
}
|
|
||||||
addToast?.(
|
|
||||||
copied
|
|
||||||
? 'ICS-ссылка скопирована. Google Calendar открыт в новой вкладке.'
|
|
||||||
: 'Google Calendar открыт. Если ссылка не подставилась, скопируйте ICS-ссылку отдельно.',
|
|
||||||
copied ? 'success' : 'info',
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
googleWindow?.close()
|
|
||||||
addToast?.(
|
|
||||||
err instanceof Error ? err.message : 'Не удалось подготовить ссылку для Google Calendar',
|
|
||||||
'error',
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
calendarActionPending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCancel(id: string) {
|
function openCancel(id: string) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
cancelModal.value = true
|
cancelModal.value = true
|
||||||
@@ -145,25 +46,7 @@ async function confirmCancel() {
|
|||||||
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
|
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="calendar-actions">
|
<button class="btn-secondary">Экспорт в календарь</button>
|
||||||
<button
|
|
||||||
class="btn-primary"
|
|
||||||
:disabled="calendarActionPending"
|
|
||||||
@click="syncWithGoogleCalendar"
|
|
||||||
>
|
|
||||||
Синхронизировать с Google Calendar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn-secondary"
|
|
||||||
:disabled="calendarActionPending"
|
|
||||||
@click="copyCalendarSubscriptionUrl"
|
|
||||||
>
|
|
||||||
Скопировать ICS-ссылку
|
|
||||||
</button>
|
|
||||||
<button class="btn-secondary" @click="downloadAllIcs">
|
|
||||||
Скачать все мои лекции (.ics)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
@@ -193,7 +76,7 @@ async function confirmCancel() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="lecture-actions">
|
<div class="lecture-actions">
|
||||||
<StatusBadge status="registered" />
|
<StatusBadge status="registered" />
|
||||||
<button class="btn-secondary btn-sm" @click="downloadLectureIcs(item.id)">Скачать .ics</button>
|
<button class="btn-secondary btn-sm">Добавить в календарь</button>
|
||||||
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
|
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
@@ -250,13 +133,6 @@ async function confirmCancel() {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.calendar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { mapApiReview } from '@/api/mappers'
|
|||||||
import { useLecturesStore } from '@/stores/lectures'
|
import { useLecturesStore } from '@/stores/lectures'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const reviews = ref<Review[]>([])
|
const reviews = ref<Review[]>([])
|
||||||
@@ -16,9 +17,8 @@ const reviews = ref<Review[]>([])
|
|||||||
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
|
const positive = computed(() => reviews.value.filter((r) => r.sentiment === 'positive').length)
|
||||||
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
|
const neutral = computed(() => reviews.value.filter((r) => r.sentiment === 'neutral').length)
|
||||||
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
|
const negative = computed(() => reviews.value.filter((r) => r.sentiment === 'negative').length)
|
||||||
const total = computed(() => reviews.value.length)
|
const total = computed(() => reviews.value.length || 1)
|
||||||
const pct = (value: number) => (total.value ? Math.round((value / total.value) * 100) : 0)
|
const pct = (value: number) => Math.round((value / total.value) * 100)
|
||||||
const ratio = (value: number) => `${value}/${total.value}`
|
|
||||||
|
|
||||||
async function fetchTeacherAnalytics() {
|
async function fetchTeacherAnalytics() {
|
||||||
if (!auth.user?.id) return
|
if (!auth.user?.id) return
|
||||||
@@ -39,28 +39,37 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
|
|||||||
<h1 class="page-title">Аналитика преподавателя</h1>
|
<h1 class="page-title">Аналитика преподавателя</h1>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
<GlassCard>
|
||||||
|
<div class="section-title">Динамика оценок</div>
|
||||||
|
<div class="chart">
|
||||||
|
<div v-for="(value, i) in ratingTrend" :key="i" class="bar">
|
||||||
|
<div class="bar-fill" :style="{ height: `${value * 18}px` }"></div>
|
||||||
|
<span class="bar-label">Нед {{ i + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="avg">Средняя оценка: 4.6</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div class="section-title">Sentiment-анализ отзывов</div>
|
<div class="section-title">Sentiment-анализ отзывов</div>
|
||||||
<div class="sentiment">
|
<div class="sentiment">
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Позитивные {{ ratio(positive) }}</div>
|
<div class="sentiment-label">Позитивные {{ pct(positive) }}%</div>
|
||||||
<ProgressBar :value="pct(positive)" :max="100" :text="ratio(positive)" />
|
<ProgressBar :value="pct(positive)" :max="100" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Нейтральные {{ ratio(neutral) }}</div>
|
<div class="sentiment-label">Нейтральные {{ pct(neutral) }}%</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
:value="pct(neutral)"
|
:value="pct(neutral)"
|
||||||
:max="100"
|
:max="100"
|
||||||
:text="ratio(neutral)"
|
|
||||||
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
|
color="linear-gradient(90deg, #7DD3FC, #BAE6FD)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sentiment-label">Негативные {{ ratio(negative) }}</div>
|
<div class="sentiment-label">Негативные {{ pct(negative) }}%</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
:value="pct(negative)"
|
:value="pct(negative)"
|
||||||
:max="100"
|
:max="100"
|
||||||
:text="ratio(negative)"
|
|
||||||
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
|
color="linear-gradient(90deg, #FCA5A5, #FECACA)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +117,32 @@ watch(() => auth.user?.id, fetchTeacherAnalytics)
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 160px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
width: 26px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background: linear-gradient(180deg, #22c55e, #86efac);
|
||||||
|
}
|
||||||
|
.bar-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.avg {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.sentiment {
|
.sentiment {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const upcoming = computed(() =>
|
|||||||
const enrolledTotal = computed(() =>
|
const enrolledTotal = computed(() =>
|
||||||
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
|
teacherLectures.value.reduce((sum, l) => sum + l.enrolledSeats, 0),
|
||||||
)
|
)
|
||||||
|
const visibility = computed(() =>
|
||||||
|
teacherLectures.value.length ? Math.min(100, Math.round(enrolledTotal.value * 4)) : 0,
|
||||||
|
)
|
||||||
|
|
||||||
function fetchTeacherLectures() {
|
function fetchTeacherLectures() {
|
||||||
if (!auth.user?.id) return
|
if (!auth.user?.id) return
|
||||||
@@ -45,7 +48,13 @@ watch(() => auth.user?.id, fetchTeacherLectures)
|
|||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
|
<StatsWidget label="Предстоящие лекции" :value="upcoming.length" icon="📅" color="green" />
|
||||||
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
|
<StatsWidget label="Записавшихся" :value="enrolledTotal" icon="👥" color="aqua" />
|
||||||
<StatsWidget label="Средняя оценка (0-1)" :value="'—'" icon="⭐" color="orange" />
|
<StatsWidget label="Средняя оценка" :value="'—'" icon="⭐" color="orange" />
|
||||||
|
<StatsWidget
|
||||||
|
label="Вовлеченность вне направления"
|
||||||
|
:value="`${visibility}%`"
|
||||||
|
icon="🌍"
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import StatusBadge from '@/components/ui/StatusBadge.vue'
|
|||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const lecturesStore = useLecturesStore()
|
const lecturesStore = useLecturesStore()
|
||||||
type DataTableColumn = { key: string; label: string; align?: 'left' | 'center' | 'right' }
|
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
const columns = [
|
||||||
{ key: 'title', label: 'Лекция' },
|
{ key: 'title', label: 'Лекция' },
|
||||||
{ key: 'date', label: 'Дата' },
|
{ key: 'date', label: 'Дата' },
|
||||||
{ key: 'status', label: 'Статус', align: 'center' },
|
{ key: 'status', label: 'Статус', align: 'center' },
|
||||||
@@ -51,8 +50,8 @@ watch(() => auth.user?.id, fetchTeacherLectures)
|
|||||||
subtitle="Backend не вернул лекции для текущего преподавателя."
|
subtitle="Backend не вернул лекции для текущего преподавателя."
|
||||||
/>
|
/>
|
||||||
<DataTable :columns="columns" :rows="rows">
|
<DataTable :columns="columns" :rows="rows">
|
||||||
<template #status="{ row }">
|
<template #status="{ value }">
|
||||||
<StatusBadge :status="row.status" />
|
<StatusBadge :status="value" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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 }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
+1
-6
@@ -1,8 +1,3 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
"baseBranchPatterns": ["dev"],
|
|
||||||
"enabledManagers": ["nuget", "npm"],
|
|
||||||
"npm": {
|
|
||||||
"managerFilePatterns": ["/^frontend/package\\.json$/"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user