3 Commits

Author SHA1 Message Date
Renovate Bot 9e5a72c53a chore(deps): update dependency microsoft.aspnetcore.authentication.jwtbearer to 10.0.8 2026-05-25 00:06:35 +00:00
serega404 a220afd078 Merge pull request 'chore: Configure Renovate' (#8) from renovate/configure into main
Create and publish a Docker image / Publish image (push) Successful in 2m23s
Reviewed-on: #8
2026-05-25 03:03:56 +03:00
Renovate Bot cd3f2c53b7 Add renovate.json 2026-05-25 00:02:52 +00:00
276 changed files with 435 additions and 36740 deletions
-40
View File
@@ -1,40 +0,0 @@
# Postgre
POSTGRES_USER=universe
POSTGRES_PASSWORD=
POSTGRES_DATABASE=universe
# Azure AD
AzureAd_Instance=https://login.microsoftonline.com/
AzureAd_TenantId=sfedu.ru
AzureAd_ClientId=
AzureAd_ClientSecret=
AzureAd_Domain=sfedu.onmicrosoft.com
AzureAd_CallbackPath=/signin-oidc
# JWT
JWT_SECRET=
JWT_ISSUER=UniVerse
JWT_AUDIENCE=UniVerse
JWT_ACCESS_TOKEN_EXPIRATION_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRATION_DAYS=30
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:3000
# LLM
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=
# Modeus API
MODEUS_API_BASE_URL=
MODEUS_API_KEY=
# Email SMTP
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_SMTP_ENABLE_SSL=true
EMAIL_SMTP_USERNAME=
EMAIL_SMTP_PASSWORD=
EMAIL_SMTP_FROM_ADDRESS=no-reply@universe.local
EMAIL_SMTP_FROM_NAME=UniVerse
-33
View File
@@ -1,33 +0,0 @@
name: Backend CI
on:
push:
branches: [ "main", "dev" ]
paths:
- 'backend/**'
pull_request:
branches: [ "main", "dev" ]
paths:
- 'backend/**'
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore backend/UniVerse.sln
- name: Build
run: dotnet build backend/UniVerse.sln --no-restore --configuration Release
- name: Test
run: dotnet test backend/UniVerse.sln --no-build --configuration Release --verbosity normal
-61
View File
@@ -1,61 +0,0 @@
name: Frontend CI
on:
push:
branches: [ "main", "dev" ]
paths:
- 'frontend/**'
pull_request:
branches: [ "main", "dev" ]
paths:
- 'frontend/**'
jobs:
build-and-check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24.x'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Audit dependencies
if: always()
run: pnpm audit --audit-level moderate
- name: Check formatting
if: always()
run: pnpm exec prettier --check src/
- name: Lint with oxlint
if: always()
run: pnpm exec oxlint .
- name: Lint with ESLint
if: always()
run: pnpm exec eslint . --max-warnings=0
- name: Type check
if: always()
run: pnpm run type-check
- name: Build
if: always()
run: pnpm run build-only
-40
View File
@@ -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
+13 -96
View File
@@ -1,48 +1,17 @@
name: 🚀 Create and publish a Docker image
name: Create and publish a Docker image
on:
push:
branches: ['main', 'dev']
branches: ['main', 'staging']
env:
BACKEND_PATH: backend
FRONTEND_PATH: frontend
SERVER_DOMAIN: ${{ gitea.server_url.replace('https://', '') }}
CONTEXT: ./backend
jobs:
detect-changes:
build-and-push-image:
runs-on: ubuntu-latest
name: Detect changes in backend and frontend
name: Publish image
container: catthehacker/ubuntu:act-latest
outputs:
backend_changed: ${{ steps.backend-changed.outputs.backend }}
frontend_changed: ${{ steps.frontend-changed.outputs.frontend }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Check for backend changes
id: backend-changed
uses: dorny/paths-filter@v2
with:
filters: |
backend:
- '${{ env.BACKEND_PATH }}/**'
- name: Check for frontend changes
id: frontend-changed
uses: dorny/paths-filter@v2
with:
filters: |
frontend:
- '${{ env.FRONTEND_PATH }}/**'
backend:
runs-on: ubuntu-latest
name: Build & publish backend image
container: catthehacker/ubuntu:act-latest
needs: [detect-changes]
if: ${{ needs.detect-changes.outputs.backend_changed == 'true' }}
permissions:
contents: read
packages: write
@@ -55,69 +24,17 @@ jobs:
id: meta
uses: https://github.com/docker/metadata-action@v4
with:
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/backend
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}
- name: Build an image from Dockerfile
run: |
cd ${{ env.CONTEXT }} &&
docker build -f UniVerse.Api/Dockerfile -t ${{ steps.meta.outputs.tags }} .
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ vars.SERVER_DOMAIN }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: ./${{ env.BACKEND_PATH }}
file: ./${{ env.BACKEND_PATH }}/UniVerse.Api/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
frontend:
runs-on: ubuntu-latest
name: Build & publish frontend image
container: catthehacker/ubuntu:act-latest
needs: [detect-changes]
if: ${{ needs.detect-changes.outputs.frontend_changed == 'true' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ vars.SERVER_DOMAIN }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: https://github.com/docker/metadata-action@v4
with:
images: ${{ vars.SERVER_DOMAIN }}/${{ gitea.repository }}/frontend
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: ./${{ env.FRONTEND_PATH }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy:
runs-on: ubuntu-latest
needs: [frontend, backend]
# always() - костыль для того, чтобы деплой выполнялся даже если один из билдов пропущен
if: github.ref == 'refs/heads/dev' && always() && (needs.backend.result == 'success' || needs.frontend.result == 'success')
name: Update stack on Portainer
steps:
- name: Deploy Stage
uses: fjogeleit/http-request-action@v1
with:
url: ${{ secrets.PORTAINER_WEBHOOK_URL }}
method: 'POST'
ignoreSsl: true
timeout: 60000
- name: Push
run: |
docker push '${{ steps.meta.outputs.tags }}'
-2
View File
@@ -141,7 +141,6 @@ $RECYCLE.BIN/
# Icon must end with two \r
Icon
# Thumbnails
._*
@@ -161,4 +160,3 @@ Network Trash Folder
Network Trash Folder
Temporary Items
.apdisk
backend/UniVerse.Api/appsettings.Development.json
+78 -231
View File
@@ -1,209 +1,56 @@
# 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`.
## Что внутри
- [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)
- Serilog
- Swagger (Swashbuckle)
- Каталог открытых лекций с поиском, фильтрацией, карточками и деталями занятия.
- Запись на лекцию и отмена записи с учетом лимитов мест и персонального лимита активных записей.
- Личный дашборд: ближайшие лекции, прогресс уровня, XP, монеты, достижения и статистика.
- Мои лекции: список записей, скачивание `.ics` для одной лекции или всего расписания, ссылка календарной подписки.
- Отзывы о лекциях с оценкой `Like`, `Neutral`, `Dislike`.
- Уведомления и профиль пользователя.
## Структура репозитория
### Для преподавателя
Код backend лежит в папке `backend/` и собран в solution `backend/UniVerse.sln`:
- Дашборд преподавателя по своим занятиям.
- Просмотр списка лекций и записей.
- Аналитика отзывов: тональность, информативность, теги LLM и агрегированные показатели.
- Работа с отзывами без раскрытия лишних персональных данных студентам.
### Для администратора
- Административная панель со статистикой пользователей, лекций, записей и ожидающих LLM-анализа отзывов.
- Управление пользователями: роли `Student`, `Teacher`, `Admin`, блокировка и разблокировка аккаунтов.
- Управление лекциями и создание новых занятий.
- Синхронизация расписания, аудиторий и преподавателей из 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.
- `backend/UniVerse.Api` — Web API (контроллеры, middleware, background services)
- `backend/UniVerse.Application` — DTO, интерфейсы сервисов, маппинги
- `backend/UniVerse.Domain` — доменные сущности/enum/исключения
- `backend/UniVerse.Infrastructure` — EF Core, миграции, реализации сервисов, внешние клиенты
## Требования
- .NET SDK 10.x.
- Node.js `^20.19.0 || >=22.12.0`.
- pnpm.
- PostgreSQL 17+ или Docker.
- Для production - Docker Engine и Docker Compose.
- .NET SDK 10 (`dotnet --version` должен показать `10.x`)
- PostgreSQL 14+ (или Docker для поднятия Postgres)
## Конфигурация
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.
- `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-настройки для уведомлений.
Можно переопределять через переменные окружения в формате `Section__Key`, например:
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. Установить зависимости frontend
```bash
pnpm -C frontend install
```
### 2. Поднять PostgreSQL
1) Поднять Postgres (пример через Docker):
```bash
docker run --rm --name universe-postgres \
@@ -214,7 +61,7 @@ docker run --rm --name universe-postgres \
postgres:18
```
### 3. Применить миграции
2) Применить миграции (первый раз потребуется `dotnet-ef`):
```bash
dotnet tool install --global dotnet-ef
@@ -225,71 +72,71 @@ dotnet ef database update \
--startup-project UniVerse.Api
```
### 4. Запустить backend
3) Запустить API:
```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
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`). Удобно для локальной разработки.
- `POST /api/v1/auth/login/microsoft` — заготовка под Microsoft Entra ID (сейчас не реализовано).
- `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me`
```bash
pnpm -C frontend install
dotnet run --project backend/UniVerse.AppHost/UniVerse.AppHost.csproj
```
Большинство методов API защищены `[Authorize]`.
Frontend обычно доступен на `http://localhost:5173`. Target API передается во frontend через `VITE_API_PROXY_TARGET`.
## Фоновый LLM-анализ отзывов
## Docker Compose
Сервис `LlmProcessingBackgroundService` раз в ~2 минуты берёт отзывы со статусом `Pending` и прогоняет через LLM-клиент.
LLM-ключ задаётся через `Llm:ApiKey`.
Production compose описан в `docker-compose-prod.yml`:
Если ключ не задан или внешний сервис недоступен — анализ будет ретраиться, а ошибки логироваться.
- `app` - ASP.NET Core backend.
- `frontend` - собранный Vue frontend и Nginx.
- `db` - PostgreSQL.
## Интеграция с Modeus
Перед запуском задайте переменные окружения для PostgreSQL, JWT, Microsoft auth, CORS и внешних интеграций:
Эндпоинты синхронизации доступны только администратору:
```bash
docker compose -f docker-compose-prod.yml up -d
```
- `POST /api/v1/sync/schedule`
- `POST /api/v1/sync/rooms`
- `POST /api/v1/sync/employees`
- `GET /api/v1/sync/status`
Тестовый compose находится в `docker-compose-test.yml`.
Ключ (если нужен) задаётся через `ModeusApi:ApiKey`.
## Тестирование
## Карта API (high-level)
Backend:
Базовый префикс: `/api/v1`.
```bash
dotnet test backend/UniVerse.sln
```
- `/auth` — логин/refresh/logout/me
- `/users` — профиль/статистика/достижения/транзакции (часть методов — только `Admin`)
- `/courses` — курсы и теги (CRUD в основном для `Admin`)
- `/lectures` — лекции, записи, посещаемость, отзывы
- `/reviews` — отзывы (создание студентом; модерация/реанализ для `Admin`)
- `/tags` — теги + дерево тегов
- `/locations` — аудитории/локации
- `/achievements` — достижения
- `/sync` — синхронизация с внешним расписанием (только `Admin`)
Frontend type-check и production build:
Точные схемы запросов/ответов удобнее смотреть в Swagger.
```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
View File
@@ -1 +0,0 @@
UniVerse
+1 -3
View File
@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders>
<Path>../frontend</Path>
</attachedFolders>
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<Project>
<PropertyGroup>
<BuildInParallel>false</BuildInParallel>
<RestoreUseStaticGraphEvaluation>true</RestoreUseStaticGraphEvaluation>
</PropertyGroup>
</Project>
@@ -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);
}
}
@@ -1,138 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Auth;
public class AuthServiceTests
{
[Fact]
public async Task RefreshTokenAsync_InactiveUser_RevokesTokenAndThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
db.RefreshTokens.Add(new RefreshToken
{
Id = 1,
UserId = 1,
Token = "refresh-token",
ExpiresAt = DateTime.UtcNow.AddDays(1),
CreatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.RefreshTokenAsync("refresh-token"));
var token = await db.RefreshTokens.SingleAsync(t => t.Token == "refresh-token");
Assert.NotNull(token.RevokedAt);
}
[Fact]
public async Task GetCurrentUserAsync_InactiveUser_ThrowsForbidden()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 1,
Email = "blocked@test.local",
IsActive = false,
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var service = CreateService(db);
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
}
[Fact]
public async Task LoginWithMicrosoftAsync_LinksScheduleTeacherBySubId()
{
await using var db = CreateDbContext();
db.Users.Add(new User
{
Id = 10,
Email = "modeus-person-1@modeus.local",
DisplayName = "Иванов Иван Иванович",
MicrosoftId = "sso-sub-1",
IsActive = true,
Roles = [new UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new TeacherProfile { UserId = 10, ModeusId = "person-1" }
});
await db.SaveChangesAsync();
var microsoftAuth = Substitute.For<IMicrosoftAuthClient>();
microsoftAuth.ExchangeAuthorizationCodeAsync("code", "http://localhost/callback", Arg.Any<CancellationToken>())
.Returns(new MicrosoftTokenResult(BuildIdToken("sso-sub-1", "teacher@sfedu.ru", "Иванов Иван Иванович")));
var service = CreateService(db, microsoftAuth);
var result = await service.LoginWithMicrosoftAsync("code", "http://localhost/callback");
Assert.Equal(10, result.Response.User.Id);
Assert.Equal("teacher@sfedu.ru", result.Response.User.Email);
Assert.Contains(UserRole.Teacher, result.Response.User.Roles);
Assert.Single(await db.Users.ToListAsync());
var user = await db.Users.Include(u => u.TeacherProfile).SingleAsync();
Assert.Equal("sso-sub-1", user.MicrosoftId);
Assert.Equal("person-1", user.TeacherProfile?.ModeusId);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"AuthServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static AuthService CreateService(AppDbContext db, IMicrosoftAuthClient? microsoftAuth = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = "test-secret-test-secret-test-secret-test-secret",
["Jwt:Issuer"] = "UniVerse.Tests",
["Jwt:Audience"] = "UniVerse.Tests",
["Jwt:AccessTokenExpirationMinutes"] = "15",
["Jwt:RefreshTokenExpirationDays"] = "30"
})
.Build();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
var notifications = Substitute.For<INotificationService>();
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
microsoftAuth ??= Substitute.For<IMicrosoftAuthClient>();
return new AuthService(db, config, microsoftAuth, gamification, notifications, NullLogger<AuthService>.Instance);
}
private static string BuildIdToken(string sub, string email, string name)
{
var token = new JwtSecurityToken(claims:
[
new Claim(JwtRegisteredClaimNames.Sub, sub),
new Claim("preferred_username", email),
new Claim("name", name)
]);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -1,321 +0,0 @@
using System.Net;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.Authorization;
/// <summary>
/// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API.
///
/// Каждый тестовый случай представляет собой кортеж:
/// (description, method, url, requiredRole, forbiddenRoles[])
///
/// Три типа сценариев для каждой конечной точки:
/// A) Анонимный → 401 Unauthorized
/// B) Неправильная роль → 403 Forbidden
/// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка)
/// </summary>
public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
public EndpointAuthorizationTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
// ─────────────────────────────────────────────────────────────────────────
// Тестовые данные
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Конечные точки, требующие аутентификации (не анонимные).
/// Формат: (description, method, url, correctRole, forbiddenRoles[])
///
/// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли.
/// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные.
/// </summary>
public static IEnumerable<object[]> AuthenticatedEndpoints()
{
// ── Auth ─────────────────────────────────────────────────────────────
yield return E("auth/logout [AnyAuth]", "POST", "api/v1/auth/logout", "Student");
yield return E("auth/me [AnyAuth]", "GET", "api/v1/auth/me", "Student");
// ── Users — current user ──────────────────────────────────────────────
yield return E("users/me GET [AnyAuth]", "GET", "api/v1/users/me", "Student");
yield return E("users/me PUT [AnyAuth]", "PUT", "api/v1/users/me", "Student",
body: """{"displayName":"Test","avatarUrl":null}""");
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/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/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
// ── Users — Admin only ────────────────────────────────────────────────
yield return E("users GET [Admin]", "GET", "api/v1/users", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} GET [Admin]", "GET", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} PUT [Admin]", "PUT", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/{id}/stats [Admin]", "GET", "api/v1/users/1/stats", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/enrollments [Admin]", "GET", "api/v1/users/1/enrollments", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/reviews [Admin]", "GET", "api/v1/users/1/reviews","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/achievements [Admin]", "GET", "api/v1/users/1/achievements","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/transactions [Admin]", "GET", "api/v1/users/1/transactions","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/role PATCH [Admin]", "PATCH", "api/v1/users/1/role", "Admin", forbidden: ["Student", "Teacher"],
body: "\"Student\"");
yield return E("users/{id}/active PATCH [Admin]", "PATCH", "api/v1/users/1/active", "Admin", forbidden: ["Student", "Teacher"],
body: "true");
// ── Courses — any auth ────────────────────────────────────────────────
yield return E("courses GET [AnyAuth]", "GET", "api/v1/courses", "Student");
yield return E("courses/{id} GET [AnyAuth]", "GET", "api/v1/courses/1", "Student");
// ── Courses — Admin only ──────────────────────────────────────────────
yield return E("courses POST [Admin]", "POST", "api/v1/courses", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Course","description":null}""");
yield return E("courses/{id} PUT [Admin]", "PUT", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Course","description":null}""");
yield return E("courses/{id} DELETE [Admin]", "DELETE", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("courses/{id}/tags POST [Admin]", "POST", "api/v1/courses/1/tags", "Admin", forbidden: ["Student", "Teacher"],
body: "1");
yield return E("courses/{id}/tags/{tagId} DELETE [Admin]","DELETE","api/v1/courses/1/tags/1","Admin",forbidden: ["Student", "Teacher"]);
// ── Lectures — any auth ───────────────────────────────────────────────
yield return E("lectures GET [AnyAuth]", "GET", "api/v1/lectures", "Student");
yield return E("lectures/{id} GET [AnyAuth]", "GET", "api/v1/lectures/1", "Student");
// ── Lectures — Admin only ─────────────────────────────────────────────
yield return E("lectures POST [Admin]", "POST", "api/v1/lectures", "Admin", forbidden: ["Student", "Teacher"],
body: """{"courseId":1,"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id} DELETE [Admin]", "DELETE", "api/v1/lectures/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Lectures — Admin OR Teacher ───────────────────────────────────────
yield return E("lectures/{id} PUT [Admin]", "PUT", "api/v1/lectures/1", "Admin", forbidden: ["Student"],
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id} PUT [Teacher]", "PUT", "api/v1/lectures/1", "Teacher", forbidden: ["Student"],
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id}/attendance PATCH [Admin]", "PATCH","api/v1/lectures/1/attendance/2","Admin", forbidden: ["Student"],
body: "true");
yield return E("lectures/{id}/attendance PATCH [Teacher]","PATCH","api/v1/lectures/1/attendance/2","Teacher",forbidden: ["Student"],
body: "true");
yield return E("lectures/{id}/enrollments GET [Admin]", "GET","api/v1/lectures/1/enrollments","Admin", forbidden: ["Student"]);
yield return E("lectures/{id}/enrollments GET [Teacher]","GET","api/v1/lectures/1/enrollments","Teacher",forbidden: ["Student"]);
yield return E("lectures/{id}/reviews GET [Admin]", "GET","api/v1/lectures/1/reviews","Admin", forbidden: ["Student"]);
yield return E("lectures/{id}/reviews GET [Teacher]", "GET","api/v1/lectures/1/reviews","Teacher",forbidden: ["Student"]);
// ── Lectures — Student only ───────────────────────────────────────────
yield return E("lectures/{id}/enroll POST [Student]", "POST", "api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
yield return E("lectures/{id}/enroll DELETE [Student]", "DELETE","api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
// ── Reviews — any auth ────────────────────────────────────────────────
yield return E("reviews/{id} PUT [AnyAuth]", "PUT", "api/v1/reviews/1", "Student",
body: """{"rating":"Like","text":"Updated"}""");
yield return E("reviews/{id} DELETE [AnyAuth]", "DELETE", "api/v1/reviews/1", "Student");
// ── Reviews — Admin OR Teacher ───────────────────────────────────────
yield return E("reviews/{id} GET [Admin]", "GET", "api/v1/reviews/1", "Admin", forbidden: ["Student"]);
yield return E("reviews/{id} GET [Teacher]", "GET", "api/v1/reviews/1", "Teacher", forbidden: ["Student"]);
// ── Reviews — Student only ────────────────────────────────────────────
yield return E("reviews POST [Student]", "POST", "api/v1/reviews", "Student", forbidden: ["Admin", "Teacher"],
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
// ── Reviews — Admin only ──────────────────────────────────────────────
yield return E("reviews GET [Admin]", "GET", "api/v1/reviews", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt GET [Admin]", "GET", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
// ── Tags — any auth ───────────────────────────────────────────────────
yield return E("tags GET [AnyAuth]", "GET", "api/v1/tags", "Student");
yield return E("tags/{id} GET [AnyAuth]", "GET", "api/v1/tags/1", "Student");
yield return E("tags/tree GET [AnyAuth]", "GET", "api/v1/tags/tree", "Student");
// ── Tags — Admin only ─────────────────────────────────────────────────
yield return E("tags POST [Admin]", "POST", "api/v1/tags", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Tag","type":"Topic","parentId":null}""");
yield return E("tags/{id} PUT [Admin]", "PUT", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Tag","type":"Topic","parentId":null}""");
yield return E("tags/{id} DELETE [Admin]", "DELETE", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Locations — any auth ──────────────────────────────────────────────
yield return E("locations GET [AnyAuth]", "GET", "api/v1/locations", "Student");
yield return E("locations/{id} GET [AnyAuth]", "GET", "api/v1/locations/1", "Student");
// ── Locations — Admin only ────────────────────────────────────────────
yield return E("locations POST [Admin]", "POST", "api/v1/locations", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
yield return E("locations/{id} PUT [Admin]", "PUT", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
yield return E("locations/{id} DELETE [Admin]", "DELETE", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Achievements — any auth ───────────────────────────────────────────
yield return E("achievements GET [AnyAuth]", "GET", "api/v1/achievements", "Student");
yield return E("achievements/{id} GET [AnyAuth]", "GET", "api/v1/achievements/1", "Student");
// ── Achievements — Admin only ─────────────────────────────────────────
yield return E("achievements POST [Admin]", "POST", "api/v1/achievements", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
yield return E("achievements/{id} PUT [Admin]", "PUT", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
yield return E("achievements/{id} DELETE [Admin]", "DELETE", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Sync — Admin only ─────────────────────────────────────────────────
yield return E("sync/schedule POST [Admin]", "POST", "api/v1/sync/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: """{"specialtyCode":null,"timeMin":null,"timeMax":null,"typeId":null}""");
yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
// ── Notifications — any auth ───────────────────────────────────────────
yield return E("notifications GET [AnyAuth]", "GET", "api/v1/notifications", "Student");
yield return E("notifications/read-all PATCH [AnyAuth]", "PATCH", "api/v1/notifications/read-all", "Student");
// ── Notifications — Admin only ─────────────────────────────────────────
yield return E("notifications/send POST [Admin]", "POST", "api/v1/notifications/send", "Admin", forbidden: ["Student", "Teacher"],
body: """{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello"}""");
yield return E("notifications/schedule POST [Admin]", "POST", "api/v1/notifications/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: $$"""{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello","sendAt":"{{DateTimeOffset.UtcNow.AddMinutes(5):O}}"}""");
}
/// <summary>
/// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401.
/// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401)
/// </summary>
public static IEnumerable<object[]> AnonymousEndpoints()
{
// login/microsoft GET перенаправляет на Microsoft — AzureAd настроен в фабрике
yield return new object[] { "auth/login/microsoft GET", "GET", "api/v1/auth/login/microsoft" };
// callback разрешает анонимный доступ — возвращает 400, если отсутствует параметр code
yield return new object[] { "auth/callback/microsoft GET", "GET", "api/v1/auth/callback/microsoft" };
// dev login доступен в окружении Development
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
"""{"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 от промежуточного ПО авторизации
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: анонимный → 401
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_Anonymous_Returns401(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
// Подготовка — без заголовка аутентификации
var request = BuildRequest(method, url, body, authHeader: null);
// Действие
var response = await _client.SendAsync(request);
// Проверка
Assert.True(
response.StatusCode == HttpStatusCode.Unauthorized,
$"[{description}] Ожидался ответ 401 Unauthorized для анонимного запроса, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: неправильная роль → 403
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_WrongRole_Returns403(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
foreach (var forbidden in forbiddenRoles)
{
// Подготовка
var request = BuildRequest(method, url, body,
authHeader: TestJwtFactory.BearerHeader(forbidden));
// Действие
var response = await _client.SendAsync(request);
// Проверка
Assert.True(
response.StatusCode == HttpStatusCode.Forbidden,
$"[{description}] Ожидался ответ 403 Forbidden для роли '{forbidden}', получено {(int)response.StatusCode} {response.StatusCode}");
}
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: правильная роль → не 401 и не 403
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_CorrectRole_PassesAuthz(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
// Подготовка
var request = BuildRequest(method, url, body,
authHeader: TestJwtFactory.BearerHeader(correctRole));
// Действие
var response = await _client.SendAsync(request);
// Проверка — принимается любой ответ, который НЕ 401/403
Assert.True(
response.StatusCode != HttpStatusCode.Unauthorized &&
response.StatusCode != HttpStatusCode.Forbidden,
$"[{description}] Роль '{correctRole}' должна успешно пройти авторизацию, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: анонимные конечные точки не должны возвращать 401
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AnonymousEndpoints))]
public async Task AnonymousEndpoint_NoToken_DoesNotReturn401(
string description, string method, string url, string? body = null)
{
var request = BuildRequest(method, url, body, authHeader: null);
var response = await _client.SendAsync(request);
Assert.True(
response.StatusCode != HttpStatusCode.Unauthorized,
$"[{description}] Анонимная конечная точка не должна возвращать 401, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Вспомогательные методы
// ─────────────────────────────────────────────────────────────────────────
private static HttpRequestMessage BuildRequest(
string method, string url, string? body, string? authHeader)
{
var request = new HttpRequestMessage(new HttpMethod(method), url);
if (authHeader != null)
request.Headers.Add("Authorization", authHeader);
if (body != null)
request.Content = new StringContent(body,
System.Text.Encoding.UTF8, "application/json");
return request;
}
/// <summary>Вспомогательный метод для компактного создания массивов объектов [MemberData].</summary>
private static object[] E(
string description,
string method,
string url,
string correctRole,
string[]? forbidden = null,
string? body = null)
=> [description, method, url, correctRole, forbidden ?? [], body];
}
@@ -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));
}
}
@@ -1,183 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Gamification;
public class GamificationServiceTests
{
[Fact]
public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User
{
Id = 1,
Email = "student@test.local",
DisplayName = "Student",
AvatarUrl = "avatar.png",
Xp = 100,
Coins = 510
});
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
Title = "Future lecture",
StartsAt = DateTime.UtcNow.AddDays(1),
EndsAt = DateTime.UtcNow.AddDays(1).AddHours(2),
IsOpen = true
});
db.LectureEnrollments.Add(new LectureEnrollment { UserId = 1, LectureId = 1 });
db.Reviews.AddRange(
new Review { Id = 1, UserId = 1, LectureId = 1, Rating = ReviewRating.Like },
new Review { Id = 2, UserId = 1, LectureId = 1, Rating = ReviewRating.Neutral },
new Review { Id = 3, UserId = 1, LectureId = 1, Rating = ReviewRating.Dislike });
db.CoinTransactions.Add(new CoinTransaction
{
UserId = 1,
Amount = 510,
Type = CoinTransactionType.AdminAdjustment,
Description = "Initial coins"
});
db.Achievements.AddRange(
Achievement(1001, "First activity", "first_activity:1", 10),
Achievement(1002, "Reviews", "reviews_written:3", 20),
Achievement(1003, "Active registrations", "active_registrations:1", 30),
Achievement(1004, "Coins earned", "coins_earned:500", 40),
Achievement(1005, "Level reached", "level_reached:2", 50),
Achievement(1006, "Profile completed", "profile_completed:1", 60),
Achievement(1007, "Old condition", "reviews_1", 100));
await db.SaveChangesAsync();
await service.CheckAndAwardAchievementsAsync(1);
await service.CheckAndAwardAchievementsAsync(1);
var user = await db.Users.FindAsync(1);
Assert.NotNull(user);
Assert.Equal(720, user!.Coins);
Assert.Equal(310, user.Xp);
Assert.Equal(6, await db.UserAchievements.CountAsync(ua => ua.UserId == 1));
Assert.False(await db.UserAchievements.AnyAsync(ua => ua.AchievementId == 1007));
Assert.Equal(6, await db.CoinTransactions.CountAsync(ct =>
ct.UserId == 1 && ct.Type == CoinTransactionType.AchievementReward));
}
[Fact]
public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, new DateTime(2025, 12, 29, 10, 0, 0, DateTimeKind.Utc)),
Lecture(2, new DateTime(2026, 1, 5, 10, 0, 0, DateTimeKind.Utc)),
Lecture(3, new DateTime(2026, 1, 12, 10, 0, 0, DateTimeKind.Utc)));
db.LectureEnrollments.AddRange(
new LectureEnrollment { UserId = 1, LectureId = 1, Attended = true },
new LectureEnrollment { UserId = 1, LectureId = 2, Attended = true },
new LectureEnrollment { UserId = 1, LectureId = 3, Attended = true });
db.Achievements.Add(Achievement(1001, "Streak", "attendance_streak_weeks:3", 10));
await db.SaveChangesAsync();
await service.CheckAndAwardAchievementsAsync(1);
Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001));
}
[Theory]
[InlineData(0, 1)]
[InlineData(99, 1)]
[InlineData(100, 2)]
[InlineData(299, 2)]
[InlineData(300, 3)]
public async Task CalculateLevelAsync_UsesDatabaseThresholds(int xp, int expectedLevel)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var level = await service.CalculateLevelAsync(xp);
Assert.Equal(expectedLevel, level);
}
[Theory]
[InlineData(120, 100, 300)]
[InlineData(350, 300, null)]
public async Task GetLevelProgressAsync_ReturnsCurrentAndNextThresholds(int xp, int currentLevelXp, int? nextLevelXp)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var progress = await service.GetLevelProgressAsync(xp);
Assert.Equal(currentLevelXp, progress.CurrentLevelXp);
Assert.Equal(nextLevelXp, progress.NextLevelXp);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"GamificationTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static GamificationService CreateService(AppDbContext db)
{
var notifications = Substitute.For<INotificationService>();
notifications.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
return new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
}
private static void SeedLevelThresholds(AppDbContext db)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Achievement Achievement(int id, string name, string condition, int coinReward) => new()
{
Id = id,
Name = name,
Condition = condition,
CoinReward = coinReward
};
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2)
};
}
@@ -1,335 +0,0 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using NSubstitute;
using UniVerse.Application.DTOs.Achievements;
using UniVerse.Application.DTOs.Auth;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Courses;
using UniVerse.Application.DTOs.Gamification;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Locations;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.DTOs.Tags;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.Tests.Helpers;
/// <summary>
/// WebApplicationFactory для интеграционных тестов.
/// Заменяет Npgsql DbContext на InMemory, создает заглушки для всех интерфейсов внешних сервисов
/// и отключает фоновую службу LLM, чтобы тестам не требовалась реальная инфраструктура.
/// </summary>
public class ApiWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Используем Development, чтобы были включены Swagger и конечная точка DevLogin
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, config) =>
{
// Внедряем настройки тестового JWT — должны совпадать с константами TestJwtFactory
var testSettings = new Dictionary<string, string?>
{
["Jwt:Secret"] = TestJwtFactory.Secret,
["Jwt:Issuer"] = TestJwtFactory.Issuer,
["Jwt:Audience"] = TestJwtFactory.Audience,
// Отключаем оркестрацию Aspire
["Aspire:Enabled"] = "false",
// Фиктивные значения Azure AD (маршруты имеют атрибут [AllowAnonymous] или тестируются отдельно)
["AzureAd:TenantId"] = "test-tenant",
["AzureAd:ClientId"] = "test-client",
// Фиктивные значения LLM / Modeus (клиенты заменяются ниже)
["Llm:BaseUrl"] = "http://localhost:9999/",
["ModeusApi:BaseUrl"] = "http://localhost:9998/",
};
config.AddInMemoryCollection(testSettings);
});
builder.ConfigureServices(services =>
{
// ── 1. Заменяем Npgsql DbContext на InMemory ──────────────────────────
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.RemoveAll<AppDbContext>();
// Удаляем все регистрации, связанные с DbContext, которые добавил хост
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Находим и удаляем все дескрипторы настроек DbContext
var dbContextDescriptors = services
.Where(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)
|| d.ImplementationType == typeof(AppDbContext))
.ToList();
foreach (var d in dbContextDescriptors) services.Remove(d);
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
// ── 2. Отключаем фоновые службы ────────────────────────────────────
// Удаляем все регистрации IHostedService, чтобы предотвратить запуск фоновой задачи LLM
var hostedServices = services
.Where(d => d.ServiceType == typeof(IHostedService))
.ToList();
foreach (var d in hostedServices) services.Remove(d);
// ── 3. Создаем заглушки для всех интерфейсов Application сервисов ─────────
ReplaceWithSubstitute<IAuthService>(services, CreateAuthServiceStub());
ReplaceWithSubstitute<IUserService>(services, CreateUserServiceStub());
ReplaceWithSubstitute<ILectureService>(services, CreateLectureServiceStub());
ReplaceWithSubstitute<IReviewService>(services, CreateReviewServiceStub());
ReplaceWithSubstitute<IReviewPromptService>(services, CreateReviewPromptServiceStub());
ReplaceWithSubstitute<ICourseService>(services, CreateCourseServiceStub());
ReplaceWithSubstitute<ITagService>(services, CreateTagServiceStub());
ReplaceWithSubstitute<ILocationService>(services, CreateLocationServiceStub());
ReplaceWithSubstitute<IAchievementService>(services, CreateAchievementServiceStub());
ReplaceWithSubstitute<IGamificationService>(services, CreateGamificationServiceStub());
ReplaceWithSubstitute<IScheduleSyncService>(services, CreateSyncServiceStub());
ReplaceWithSubstitute<ILlmAnalysisService>(services, Substitute.For<ILlmAnalysisService>());
ReplaceWithSubstitute<ILlmClient>(services, Substitute.For<ILlmClient>());
ReplaceWithSubstitute<IMicrosoftAuthClient>(services, Substitute.For<IMicrosoftAuthClient>());
ReplaceWithSubstitute<INotificationService>(services, CreateNotificationServiceStub());
});
}
private static void ReplaceWithSubstitute<TService>(IServiceCollection services, TService instance)
where TService : class
{
services.RemoveAll<TService>();
services.AddScoped<TService>(_ => instance);
}
// ── Фабрики заглушек ────────────────────────────────────────────────────────────
private static IAuthService CreateAuthServiceStub()
{
var stub = Substitute.For<IAuthService>();
var authResult = new AuthResult(
new AuthResponse("access_token", DateTime.UtcNow.AddHours(1),
new UserAuthDto(1, "test@test.com", "Test User", [UserRole.Student])),
"refresh_token");
stub.LoginWithMicrosoftAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<string?>())
.Returns(authResult);
stub.DevLoginAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<IReadOnlyCollection<UserRole>>(), Arg.Any<string?>())
.Returns(authResult);
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
stub.GetCurrentUserAsync(Arg.Any<int>())
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
return stub;
}
private static INotificationService CreateNotificationServiceStub()
{
var stub = Substitute.For<INotificationService>();
stub.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
stub.ScheduleAsync(Arg.Any<ScheduleNotificationRequest>(), Arg.Any<CancellationToken>())
.Returns(new ScheduledNotificationResponse("test-job", DateTimeOffset.UtcNow.AddMinutes(5)));
stub.GetUserNotificationsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<CancellationToken>())
.Returns(PagedResult<UserNotificationDto>.Create([], 0, 1, 20));
stub.MarkAllReadAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
stub.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(new UserNotificationDto(1, "achievement", "Title", "Body", false, DateTime.UtcNow));
return stub;
}
private static IUserService CreateUserServiceStub()
{
var stub = Substitute.For<IUserService>();
var userDto = new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow);
var pagedUsers = PagedResult<UserDto>.Create([userDto], 1, 1, 20);
var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null,
"Title", null, LectureFormat.Offline,
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
true, 30, 0, null, DateTime.UtcNow, true);
var pagedLectures = PagedResult<LectureDto>.Create([lectureDto], 1, 1, 20);
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(
0,
0,
0,
0,
0,
1,
0,
0,
100,
0,
3,
[new EnrollmentSlotRuleDto(1, 3), new EnrollmentSlotRuleDto(3, 5), new EnrollmentSlotRuleDto(4, 7)]));
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.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
return stub;
}
private static ILectureService CreateLectureServiceStub()
{
var stub = Substitute.For<ILectureService>();
var lectureDto = new LectureDto(1, 1, "Course", null, null, null, null,
"Title", null, LectureFormat.Offline,
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
true, 30, 0, null, DateTime.UtcNow);
var detailDto = new LectureDetailDto(1, 1, "Course", null, null, null, null,
"Title", null, LectureFormat.Offline,
DateTime.UtcNow, DateTime.UtcNow.AddHours(2),
true, 30, 0, null, DateTime.UtcNow, false);
var pagedLectures = PagedResult<LectureDto>.Create([lectureDto], 1, 1, 20);
var pagedEnrollments = PagedResult<EnrollmentDto>.Create([], 0, 1, 20);
stub.GetAllAsync(Arg.Any<LectureFilterRequest>(), Arg.Any<int?>()).Returns(pagedLectures);
stub.GetByIdAsync(Arg.Any<int>(), Arg.Any<int?>()).Returns(detailDto);
stub.CreateAsync(Arg.Any<CreateLectureRequest>()).Returns(lectureDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLectureRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(lectureDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.EnrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.UnenrollAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.MarkAttendanceAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetEnrollmentsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(pagedEnrollments);
return stub;
}
private static IReviewService CreateReviewServiceStub()
{
var stub = Substitute.For<IReviewService>();
var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User",
ReviewRating.Like, "Great!", ReviewLlmStatus.Pending,
null, null, null, null, null, DateTime.UtcNow);
var pagedReviews = PagedResult<ReviewDto>.Create([reviewDto], 1, 1, 20);
stub.CreateAsync(Arg.Any<int>(), Arg.Any<CreateReviewRequest>()).Returns(reviewDto);
stub.GetByIdAsync(Arg.Any<int>()).Returns(reviewDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<UpdateReviewRequest>()).Returns(reviewDto);
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>(), Arg.Any<int?>(), Arg.Any<bool>()).Returns(pagedReviews);
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static IReviewPromptService CreateReviewPromptServiceStub()
{
var stub = Substitute.For<IReviewPromptService>();
var promptDto = new ReviewPromptDto(
"Analyze {lectureContext}. Review: {reviewText}",
DateTime.UtcNow);
stub.GetAsync().Returns(promptDto);
stub.UpdateAsync(Arg.Any<UpdateReviewPromptRequest>()).Returns(callInfo =>
new ReviewPromptDto(callInfo.Arg<UpdateReviewPromptRequest>().Prompt, DateTime.UtcNow));
return stub;
}
private static ICourseService CreateCourseServiceStub()
{
var stub = Substitute.For<ICourseService>();
var courseDto = new CourseDto(1, "Course", null, false, [], DateTime.UtcNow);
var paged = PagedResult<CourseDto>.Create([courseDto], 1, 1, 20);
stub.GetAllAsync(Arg.Any<CourseFilterRequest>()).Returns(paged);
stub.GetByIdAsync(Arg.Any<int>()).Returns(courseDto);
stub.CreateAsync(Arg.Any<CreateCourseRequest>()).Returns(courseDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateCourseRequest>()).Returns(courseDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.AddTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
stub.RemoveTagAsync(Arg.Any<int>(), Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static ITagService CreateTagServiceStub()
{
var stub = Substitute.For<ITagService>();
var tagDto = new TagDto(1, "Tag", TagType.Topic, null, DateTime.UtcNow);
stub.GetAllAsync(Arg.Any<TagType?>(), Arg.Any<int?>()).Returns([tagDto]);
stub.GetByIdAsync(Arg.Any<int>()).Returns(tagDto);
stub.CreateAsync(Arg.Any<CreateTagRequest>()).Returns(tagDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateTagRequest>()).Returns(tagDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.GetTreeAsync().Returns(new List<TagTreeDto>());
return stub;
}
private static ILocationService CreateLocationServiceStub()
{
var stub = Substitute.For<ILocationService>();
var locationDto = new LocationDto(1, "Room 101", null, null, null, DateTime.UtcNow);
stub.GetAllAsync().Returns([locationDto]);
stub.GetByIdAsync(Arg.Any<int>()).Returns(locationDto);
stub.CreateAsync(Arg.Any<CreateLocationRequest>()).Returns(locationDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateLocationRequest>()).Returns(locationDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static IAchievementService CreateAchievementServiceStub()
{
var stub = Substitute.For<IAchievementService>();
var achievementDto = new AchievementDto(1, "First Review", null, null, 10, 5, null, DateTime.UtcNow);
stub.GetAllAsync().Returns([achievementDto]);
stub.GetByIdAsync(Arg.Any<int>()).Returns(achievementDto);
stub.CreateAsync(Arg.Any<CreateAchievementRequest>()).Returns(achievementDto);
stub.UpdateAsync(Arg.Any<int>(), Arg.Any<UpdateAchievementRequest>()).Returns(achievementDto);
stub.DeleteAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
private static IGamificationService CreateGamificationServiceStub()
{
var stub = Substitute.For<IGamificationService>();
var paged = PagedResult<CoinTransactionDto>.Create([], 0, 1, 20);
stub.GetUserAchievementsAsync(Arg.Any<int>()).Returns(new List<UserAchievementDto>());
stub.GetTransactionsAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(paged);
stub.AwardCoinsAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CoinTransactionType>(),
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>()).Returns(Task.CompletedTask);
stub.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.CalculateLevelAsync(Arg.Any<int>()).Returns(Task.FromResult(1));
stub.GetLevelProgressAsync(Arg.Any<int>()).Returns(Task.FromResult(new LevelProgressDto(0, 100)));
return stub;
}
private static IScheduleSyncService CreateSyncServiceStub()
{
var stub = Substitute.For<IScheduleSyncService>();
var syncResult = new SyncResultDto(0, 0, 0, null);
var syncStatus = new SyncStatusDto(null, "idle", null);
stub.SyncScheduleAsync(Arg.Any<SyncScheduleRequest>()).Returns(syncResult);
stub.SyncRoomsAsync().Returns(syncResult);
stub.SearchEmployeesAsync(Arg.Any<string>()).Returns(new List<EmployeeDto>());
stub.GetLastSyncStatusAsync().Returns(syncStatus);
return stub;
}
}
@@ -1,44 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace UniVerse.Api.Tests.Helpers;
/// <summary>
/// Генерирует подписанные JWT токены для использования в интеграционных тестах.
/// Использует те же секрет/издателя/аудиторию (secret/issuer/audience), которые внедряет ApiWebApplicationFactory.
/// </summary>
public static class TestJwtFactory
{
public const string Secret = "test-super-secret-key-32-chars!!";
public const string Issuer = "UniVerse-Test";
public const string Audience = "UniVerse-Test";
/// <summary>Создает валидную строку токена JWT (bearer) для заданной роли и идентификатора пользователя.</summary>
public static string Generate(string role, int userId = 1)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim(ClaimTypes.Role, role),
new Claim("sub", userId.ToString()),
};
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Создает значение заголовка Authorization: "Bearer &lt;token&gt;".</summary>
public static string BearerHeader(string role, int userId = 1)
=> $"Bearer {Generate(role, userId)}";
}
@@ -1,264 +0,0 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Lectures;
public class LectureServiceTests
{
[Fact]
public async Task GetAllAsync_MarksLecturesEnrolledByCurrentUser()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
var startsAt = DateTime.UtcNow.AddDays(1);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, startsAt),
Lecture(2, startsAt.AddDays(1)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = 1, UserId = 1 });
await db.SaveChangesAsync();
var result = await service.GetAllAsync(new LectureFilterRequest(null, null, null, null, null, null, null, null), 1);
Assert.True(result.Items.Single(item => item.Id == 1).IsEnrolled);
Assert.False(result.Items.Single(item => item.Id == 2).IsEnrolled);
}
[Fact]
public async Task EnrollAsync_SchedulesLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddHours(4);
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(1, startsAt));
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 3 часа")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-3))),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("через 1 час")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(-1))),
"lecture-1-user-1-starts-in-1-hour",
Arg.Any<CancellationToken>());
await scheduler.Received(1).ScheduleAsync(
Arg.Is<NotificationMessage>(m => m.Recipient == "student@test.local" && m.Subject.Contains("Оцените")),
Arg.Is<DateTimeOffset>(d => d == new DateTimeOffset(startsAt.AddHours(2))),
"lecture-1-user-1-ended",
Arg.Any<CancellationToken>());
}
[Fact]
public async Task EnrollAsync_SkipsPastLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddMinutes(90);
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));
await db.SaveChangesAsync();
await service.EnrollAsync(1, 1);
await scheduler.DidNotReceive().ScheduleAsync(
Arg.Any<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
"lecture-1-user-1-starts-in-3-hours",
Arg.Any<CancellationToken>());
await scheduler.Received(2).ScheduleAsync(
Arg.Any<NotificationMessage>(),
Arg.Any<DateTimeOffset>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[Theory]
[InlineData(1, 3)]
[InlineData(2, 3)]
[InlineData(3, 5)]
[InlineData(4, 7)]
[InlineData(5, 7)]
public async Task EnrollAsync_ThrowsWhenActiveEnrollmentLimitReached(int level, int activeEnrollments)
{
await using var db = CreateDbContext();
var gamification = Substitute.For<IGamificationService>();
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(level);
var service = new LectureService(db, gamification, Substitute.For<INotificationScheduler>());
var startsAt = DateTime.UtcNow.AddDays(1);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, startsAt.AddDays(100)));
for (var i = 1; i <= activeEnrollments; i++)
{
db.Lectures.Add(Lecture(i, startsAt.AddDays(i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_ThrowsWhenPastUnattendedEnrollmentsReachLimit()
{
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 now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1 });
}
await db.SaveChangesAsync();
await Assert.ThrowsAsync<ConflictException>(() => service.EnrollAsync(100, 1));
}
[Fact]
public async Task EnrollAsync_DoesNotCountAttendedEnrollmentsTowardLimit()
{
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 now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(Lecture(100, now.AddDays(1)));
for (var i = 1; i <= 3; i++)
{
db.Lectures.Add(Lecture(i, now.AddDays(-i)));
db.LectureEnrollments.Add(new LectureEnrollment { LectureId = i, UserId = 1, Attended = true });
}
await db.SaveChangesAsync();
await service.EnrollAsync(100, 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]
public async Task UnenrollAsync_CancelsLectureReminders()
{
await using var db = CreateDbContext();
var scheduler = Substitute.For<INotificationScheduler>();
var service = new LectureService(db, Substitute.For<IGamificationService>(), scheduler);
var startsAt = DateTime.UtcNow.AddHours(4);
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();
await service.UnenrollAsync(1, 1);
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-3-hours", Arg.Any<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-starts-in-1-hour", Arg.Any<CancellationToken>());
await scheduler.Received(1).CancelAsync("lecture-1-user-1-ended", Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_TeacherCannotUpdateAnotherTeachersLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var request = new UpdateLectureRequest(null, null, "Updated", null, Domain.Enums.LectureFormat.Offline,
DateTime.UtcNow.AddDays(1), DateTime.UtcNow.AddDays(1).AddHours(2), true, 30, null);
await Assert.ThrowsAsync<ForbiddenException>(() => service.UpdateAsync(1, request, currentUserId: 1));
}
[Fact]
public async Task GetEnrollmentsAsync_AdminCanReadAnyLecture()
{
await using var db = CreateDbContext();
var service = new LectureService(db, Substitute.For<IGamificationService>(), Substitute.For<INotificationScheduler>());
db.Courses.Add(new Course { Id = 1, Name = "Course" });
var lecture = Lecture(1, DateTime.UtcNow.AddDays(1));
lecture.TeacherId = 2;
db.Lectures.Add(lecture);
await db.SaveChangesAsync();
var result = await service.GetEnrollmentsAsync(1, new UniVerse.Application.DTOs.Common.PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Empty(result.Items);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"LectureServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 30
};
}
@@ -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,91 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class LlmAnalysisServiceTests
{
[Fact]
public async Task AnalyzeReviewAsync_SavesParsedAnalysisResult()
{
await using var db = CreateDbContext();
await SeedPendingReviewAsync(db);
var llm = Substitute.For<ILlmClient>();
llm.AnalyzeReviewAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(new LlmReviewAnalysis(
0.76,
"Положительный",
["lecture structure", "practical examples"],
true,
"{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}"));
var gamification = Substitute.For<IGamificationService>();
gamification.AwardCoinsAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CoinTransactionType>(),
Arg.Any<int?>(),
Arg.Any<int?>(),
Arg.Any<string?>())
.Returns(Task.CompletedTask);
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
var service = new LlmAnalysisService(db, llm, gamification, NullLogger<LlmAnalysisService>.Instance);
await service.AnalyzeReviewAsync(1);
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Analyzed, review.LlmStatus);
Assert.Equal(ReviewSentiment.Positive, review.Sentiment);
Assert.Equal(0.76, review.QualityScore);
Assert.True(review.IsInformative);
Assert.Equal(["lecture structure", "practical examples"], review.LlmTags!);
Assert.Equal("{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}", review.LlmRawOutput);
await gamification.Received(1).AwardCoinsAsync(
1,
10,
CoinTransactionType.ReviewReward,
1,
null,
"Informative review reward");
}
private static async Task SeedPendingReviewAsync(AppDbContext db)
{
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
Title = "Lecture",
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
IsOpen = true,
MaxEnrollments = 30
});
db.Reviews.Add(new Review
{
Id = 1,
LectureId = 1,
UserId = 1,
Rating = ReviewRating.Like,
Text = "Useful review",
LlmStatus = ReviewLlmStatus.Pending
});
await db.SaveChangesAsync();
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"LlmAnalysisServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -1,96 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Infrastructure.Data;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewAnalysisWorkerTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task Worker_DoesNotExceedConfiguredConcurrency(int maxConcurrentProcessing)
{
var queue = new ReviewAnalysisQueue();
var analysisService = new RecordingLlmAnalysisService();
await using var provider = CreateServiceProvider(analysisService);
var worker = new ReviewAnalysisWorker(
provider,
queue,
Microsoft.Extensions.Options.Options.Create(
new ReviewAnalysisOptions { MaxConcurrentProcessing = maxConcurrentProcessing }),
NullLogger<ReviewAnalysisWorker>.Instance);
for (var reviewId = 1; reviewId <= 6; reviewId++)
await queue.EnqueueAsync(reviewId);
analysisService.ExpectProcessed(6);
await worker.StartAsync(CancellationToken.None);
await analysisService.WaitForProcessedAsync();
await worker.StopAsync(CancellationToken.None);
Assert.True(
analysisService.MaxRunning <= maxConcurrentProcessing,
$"Expected at most {maxConcurrentProcessing} concurrent analyses, got {analysisService.MaxRunning}.");
}
private static ServiceProvider CreateServiceProvider(ILlmAnalysisService analysisService)
{
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"ReviewAnalysisWorkerTests_{Guid.NewGuid()}"));
services.AddScoped(_ => analysisService);
return services.BuildServiceProvider();
}
private sealed class RecordingLlmAnalysisService : ILlmAnalysisService
{
private readonly TaskCompletionSource _processedAll = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _expectedCount;
private int _processedCount;
private int _running;
private int _maxRunning;
public int MaxRunning => _maxRunning;
public void ExpectProcessed(int expectedCount)
{
Volatile.Write(ref _expectedCount, expectedCount);
}
public async Task AnalyzeReviewAsync(int reviewId)
{
var running = Interlocked.Increment(ref _running);
UpdateMaxRunning(running);
await Task.Delay(50);
Interlocked.Decrement(ref _running);
if (Interlocked.Increment(ref _processedCount) >= Volatile.Read(ref _expectedCount))
_processedAll.TrySetResult();
}
public async Task WaitForProcessedAsync()
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var registration = timeout.Token.Register(() => _processedAll.TrySetCanceled(timeout.Token));
await _processedAll.Task;
}
private void UpdateMaxRunning(int running)
{
while (true)
{
var current = Volatile.Read(ref _maxRunning);
if (running <= current) return;
if (Interlocked.CompareExchange(ref _maxRunning, running, current) == current) return;
}
}
}
}
@@ -1,184 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.Interfaces;
using UniVerse.Application.Prompts;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.ExternalServices;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewPromptServiceTests
{
[Fact]
public async Task GetAsync_ReturnsDefaultPrompt_WhenSettingDoesNotExist()
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
var result = await service.GetAsync();
Assert.Equal(ReviewPromptTemplate.Default, result.Prompt);
Assert.Null(result.UpdatedAt);
}
[Fact]
public async Task UpdateAsync_UpsertsSingletonPrompt()
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
await service.UpdateAsync(new UpdateReviewPromptRequest("First {lectureContext} {reviewText}"));
var result = await service.UpdateAsync(new UpdateReviewPromptRequest("Second {lectureContext} {reviewText}"));
Assert.Equal("Second {lectureContext} {reviewText}", result.Prompt);
Assert.NotNull(result.UpdatedAt);
Assert.Equal(1, await db.ReviewPromptSettings.CountAsync());
Assert.Equal("Second {lectureContext} {reviewText}", (await service.GetAsync()).Prompt);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Prompt without placeholders")]
[InlineData("Only lecture {lectureContext}")]
[InlineData("Only review {reviewText}")]
public async Task UpdateAsync_RejectsInvalidPrompt(string prompt)
{
await using var db = CreateDbContext();
var service = new ReviewPromptService(db);
await Assert.ThrowsAsync<BadRequestException>(() =>
service.UpdateAsync(new UpdateReviewPromptRequest(prompt)));
}
[Fact]
public async Task AnalyzeReviewAsync_RendersCustomPrompt()
{
var handler = new CapturingHandler();
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://llm.test/")
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(
"Custom prompt. Context: {lectureContext}. Text: {reviewText}",
DateTime.UtcNow));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
Assert.NotNull(handler.RequestBody);
using var requestJson = JsonDocument.Parse(handler.RequestBody!);
var content = requestJson.RootElement
.GetProperty("messages")[0]
.GetProperty("content")
.GetString();
Assert.Contains("Custom prompt", content);
Assert.Contains("Lecture: Algebra", content);
Assert.Contains("Very useful review", content);
Assert.DoesNotContain(ReviewPromptTemplate.LectureContextPlaceholder, content);
Assert.DoesNotContain(ReviewPromptTemplate.ReviewTextPlaceholder, content);
}
[Fact]
public async Task AnalyzeReviewAsync_ParsesSnakeCaseJsonFromFencedResponse()
{
var handler = new CapturingHandler("""
```json
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
```
""");
var http = new HttpClient(handler)
{
BaseAddress = new Uri("https://llm.test/")
};
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.Instance);
var result = await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra");
Assert.Equal(0.82, result.QualityScore);
Assert.Equal("Положительный", result.Sentiment);
Assert.Equal(["lecture structure", "practical examples"], result.Tags);
Assert.True(result.IsInformative);
Assert.Equal("""
```json
{"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true}
```
""", result.RawOutput);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private sealed class CapturingHandler : HttpMessageHandler
{
private readonly string _analysisContent;
public CapturingHandler(string? analysisContent = null)
{
_analysisContent = analysisContent ??
"{\"quality_score\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"is_informative\":true}";
}
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
var responsePayload = JsonSerializer.Serialize(new
{
choices = new[]
{
new
{
message = new
{
content = _analysisContent
}
}
}
});
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responsePayload, Encoding.UTF8, "application/json")
};
}
}
}
@@ -1,145 +0,0 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using UniVerse.Application.DTOs.Reviews;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Reviews;
public class ReviewServiceTests
{
[Fact]
public async Task CreateAsync_EnqueuesReviewAnalysis()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedLectureAsync(db);
var result = await service.CreateAsync(1, new CreateReviewRequest(1, ReviewRating.Like, "Great lecture"));
await queue.Received(1).EnqueueAsync(result.Id, Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedAnalyzedReviewAsync(db);
await service.UpdateAsync(1, 1, new UpdateReviewRequest(ReviewRating.Neutral, "Updated text"));
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
Assert.Null(review.Sentiment);
Assert.Null(review.QualityScore);
Assert.Null(review.IsInformative);
Assert.Null(review.LlmTags);
Assert.Null(review.LlmRawOutput);
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ReanalyzeAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
var service = CreateService(db, queue);
await SeedAnalyzedReviewAsync(db);
await service.ReanalyzeAsync(1);
var review = await db.Reviews.SingleAsync(r => r.Id == 1);
Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus);
Assert.Null(review.Sentiment);
Assert.Null(review.QualityScore);
Assert.Null(review.IsInformative);
Assert.Null(review.LlmTags);
Assert.Null(review.LlmRawOutput);
await queue.Received(1).EnqueueAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetByLectureAsync_TeacherCannotReadAnotherTeachersReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
await Assert.ThrowsAsync<ForbiddenException>(() =>
service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1));
}
[Fact]
public async Task GetByLectureAsync_AdminCanReadAnyLectureReviews()
{
await using var db = CreateDbContext();
var service = CreateService(db, Substitute.For<IReviewAnalysisQueue>());
await SeedAnalyzedReviewAsync(db, teacherId: 2);
var result = await service.GetByLectureAsync(1, new PaginationRequest(), currentUserId: 1, isAdmin: true);
Assert.Single(result.Items);
}
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
{
var gamification = Substitute.For<IGamificationService>();
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return new ReviewService(db, gamification, queue);
}
private static async Task SeedLectureAsync(AppDbContext db, int? teacherId = null)
{
db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.Add(new Lecture
{
Id = 1,
CourseId = 1,
TeacherId = teacherId,
Title = "Lecture",
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2),
IsOpen = true,
MaxEnrollments = 30
});
await db.SaveChangesAsync();
}
private static async Task SeedAnalyzedReviewAsync(AppDbContext db, int? teacherId = null)
{
await SeedLectureAsync(db, teacherId);
db.Reviews.Add(new Review
{
Id = 1,
LectureId = 1,
UserId = 1,
Rating = ReviewRating.Like,
Text = "Original text",
LlmStatus = ReviewLlmStatus.Analyzed,
Sentiment = ReviewSentiment.Positive,
QualityScore = 0.9,
IsInformative = true,
LlmTags = ["clear"],
LlmRawOutput = "{\"quality_score\":0.9}"
});
await db.SaveChangesAsync();
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ReviewServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -1,49 +0,0 @@
using System.Net;
using System.Text.Json;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.Swagger;
public class SwaggerDocumentTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
public SwaggerDocumentTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task SwaggerJson_IsGenerated()
{
var response = await _client.GetAsync("api/docs/v1/swagger.json");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = document.RootElement;
Assert.Equal("UniVerse API", root.GetProperty("info").GetProperty("title").GetString());
Assert.True(root.GetProperty("components").GetProperty("securitySchemes").TryGetProperty("Bearer", out _));
}
[Fact]
public async Task SwaggerJson_DocumentsSecurityOnlyForAuthorizedEndpoints()
{
using var document = JsonDocument.Parse(await _client.GetStringAsync("api/docs/v1/swagger.json"));
var paths = document.RootElement.GetProperty("paths");
var publicOperation = paths
.GetProperty("/api/v1/auth/login/dev")
.GetProperty("post");
var protectedOperation = paths
.GetProperty("/api/v1/users")
.GetProperty("get");
Assert.False(publicOperation.TryGetProperty("security", out _));
Assert.True(protectedOperation.TryGetProperty("security", out var security));
Assert.Equal("Bearer", security[0].EnumerateObject().Single().Name);
Assert.Contains("Required roles:", protectedOperation.GetProperty("description").GetString());
}
}
@@ -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":[]}""")
};
}
}
}
@@ -1,425 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Sync;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Sync;
public class ScheduleSyncServiceTests
{
private const string EventId = "48102128-2224-4cb9-ae8f-a91d0b7c512a";
private const string CourseId = "73aa6226-adbb-4e15-b264-e16fee19fd73";
private const string PersonId = "b5a5cad8-60c2-4d94-9972-8a0c2e981440";
private const string FullName = "Иванов Иван Иванович";
[Fact]
public async Task SyncScheduleAsync_UsesRoomWorkingCapacityForLectureSeats()
{
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)
}
],
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: 60, WorkingCapacity: 42)
],
EventTeams =
[
new ModeusEventTeam("event-1", 15)
]
}
});
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(42, lecture.MaxEnrollments);
}
[Fact]
public async Task SyncScheduleAsync_LoadsRoomCapacityWhenEventRoomHasNoCapacity()
{
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)
}
],
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: null, WorkingCapacity: null)
],
EventTeams =
[
new ModeusEventTeam("event-1", 15)
]
}
});
modeus.SearchRoomsAsync()
.Returns(new ModeusRoomsResponse
{
Rooms =
[
new ModeusRoom("room-1", "Room 101", "101", null, TotalCapacity: 60, WorkingCapacity: 48)
]
});
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(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]
public async Task SyncScheduleAsync_UsesModeusEventAttendeeTeacher()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse());
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Equal(1, result.Created);
var lecture = await db.Lectures.Include(item => item.Teacher).SingleAsync();
Assert.Equal("Иванов Иван Иванович", lecture.Teacher?.DisplayName);
Assert.Equal("modeus-b5a5cad8-60c2-4d94-9972-8a0c2e981440@modeus.local", lecture.Teacher?.Email);
var teacherProfile = await db.TeacherProfiles.Include(item => item.User).SingleAsync();
Assert.Equal("b5a5cad8-60c2-4d94-9972-8a0c2e981440", teacherProfile.ModeusId);
Assert.Equal(teacherProfile.UserId, lecture.TeacherId);
var teacherRole = await db.UserRoles.SingleAsync();
Assert.Equal(lecture.TeacherId, teacherRole.UserId);
Assert.Equal(UserRole.Teacher, teacherRole.Role);
}
[Fact]
public async Task SyncScheduleAsync_SavesResolvedTeacherSubId()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal("sso-sub-1", teacher.MicrosoftId);
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
}
[Fact]
public async Task SyncScheduleAsync_UsesPlaceholderWhenSubLookupFails()
{
await using var db = CreateDbContext();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), throwOnSubLookup: true);
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
var teacher = await db.Users.Include(user => user.TeacherProfile).SingleAsync();
Assert.Null(teacher.MicrosoftId);
Assert.Equal($"modeus-{PersonId}@modeus.local", teacher.Email);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
}
[Fact]
public async Task SyncScheduleAsync_AttachesTeacherProfileToExistingSsoUser()
{
await using var db = CreateDbContext();
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 77,
Email = "teacher@sfedu.ru",
DisplayName = "Old Name",
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 77, Role = UserRole.Student }]
});
await db.SaveChangesAsync();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Single(await db.Users.ToListAsync());
var teacher = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal(77, teacher.Id);
Assert.Equal("teacher@sfedu.ru", teacher.Email);
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Student);
Assert.Contains(teacher.Roles, role => role.Role == UserRole.Teacher);
Assert.Equal(PersonId, teacher.TeacherProfile?.ModeusId);
Assert.True(await db.Lectures.AnyAsync(lecture => lecture.TeacherId == 77));
}
[Fact]
public async Task SyncScheduleAsync_MergesPlaceholderIntoExistingSsoUserOnRetry()
{
await using var db = CreateDbContext();
var placeholder = new UniVerse.Domain.Entities.User
{
Id = 10,
Email = $"modeus-{PersonId}@modeus.local",
DisplayName = FullName,
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
};
db.Users.Add(placeholder);
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 20,
Email = "teacher@sfedu.ru",
DisplayName = FullName,
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 20, Role = UserRole.Student }]
});
db.Courses.Add(new UniVerse.Domain.Entities.Course { Id = 1, Name = "Course", ExternalId = CourseId, IsSynced = true });
db.Lectures.Add(new UniVerse.Domain.Entities.Lecture
{
Id = 1,
CourseId = 1,
TeacherId = 10,
ExternalId = EventId,
Title = "Old",
StartsAt = DateTime.UtcNow,
EndsAt = DateTime.UtcNow.AddHours(1)
});
await db.SaveChangesAsync();
var modeus = new FakeModeusApiClient(BuildEventsResponse(), subId: "sso-sub-1");
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
Assert.Single(await db.Users.ToListAsync());
var realUser = await db.Users.Include(user => user.Roles).Include(user => user.TeacherProfile).SingleAsync();
Assert.Equal(20, realUser.Id);
Assert.Equal(PersonId, realUser.TeacherProfile?.ModeusId);
Assert.Contains(realUser.Roles, role => role.Role == UserRole.Teacher);
Assert.True(await db.Lectures.AllAsync(lecture => lecture.TeacherId == 20));
}
[Fact]
public async Task SyncScheduleAsync_DoesNotLookupSubWhenTeacherAlreadyHasMicrosoftId()
{
await using var db = CreateDbContext();
db.Users.Add(new UniVerse.Domain.Entities.User
{
Id = 10,
Email = "teacher@sfedu.ru",
DisplayName = FullName,
MicrosoftId = "sso-sub-1",
Roles = [new UniVerse.Domain.Entities.UserRoleAssignment { UserId = 10, Role = UserRole.Teacher }],
TeacherProfile = new UniVerse.Domain.Entities.TeacherProfile { UserId = 10, ModeusId = PersonId }
});
await db.SaveChangesAsync();
var modeus = Substitute.For<IModeusApiClient>();
modeus.SearchEventsAsync(Arg.Any<SyncScheduleRequest>()).Returns(BuildEventsResponse());
var service = new ScheduleSyncService(db, modeus, NullLogger<ScheduleSyncService>.Instance);
var result = await service.SyncScheduleAsync(new SyncScheduleRequest(null, null, null, null));
Assert.Null(result.Error);
await modeus.DidNotReceive().GetSubIdByFullNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"ScheduleSyncTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static ModeusEventsResponse BuildEventsResponse()
{
const string attendeeId = "a894db4e-833f-4f52-a153-fdd7c7d32ca7";
return new ModeusEventsResponse
{
Embedded = new ModeusEventsEmbedded
{
Events =
[
new ModeusEvent
{
Id = EventId,
Name = "Тема 20. Управление ресурсами проекта. Часть 2.",
TypeId = "LAB",
StartsAt = new DateTime(2026, 4, 14, 5, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2026, 4, 14, 6, 35, 0, DateTimeKind.Utc),
Links = new ModeusEventLinks
{
CourseUnitRealization = new ModeusHrefLink($"/{CourseId}")
}
}
],
CourseUnitRealizations =
[
new ModeusCourseUnitRealization(
CourseId,
"Управление проектами разработки программного обеспечения",
"УПРПО")
],
EventTeams = [new ModeusEventTeam(EventId, 25)],
EventAttendees =
[
new ModeusEventAttendee
{
Id = attendeeId,
RoleId = "TEACH",
RoleName = "Преподаватель",
Links = new ModeusEventAttendeeLinks
{
Event = new ModeusHrefLink($"/{EventId}"),
Person = new ModeusHrefLink($"/{PersonId}")
}
}
],
Persons =
[
new ModeusPerson(
PersonId,
"Иванов",
"Иван",
"Иванович",
FullName)
]
}
};
}
private sealed class FakeModeusApiClient(
ModeusEventsResponse events,
string? subId = null,
bool throwOnSubLookup = false) : IModeusApiClient
{
public Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request) => Task.FromResult(events);
public Task<ModeusRoomsResponse> SearchRoomsAsync() => Task.FromResult(new ModeusRoomsResponse());
public Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) => Task.FromResult(new List<ModeusEmployee>());
public Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default)
{
if (throwOnSubLookup)
throw new HttpRequestException("lookup failed");
return Task.FromResult(subId);
}
}
}
@@ -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);
}
}
@@ -1,31 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
</ItemGroup>
</Project>
@@ -1,324 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Domain.Enums;
using UniVerse.Domain.Exceptions;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Users;
public class UserServiceTests
{
[Fact]
public async Task GetStatsAsync_ReturnsLevelProgressThresholds()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 120 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(2, stats.Level);
Assert.Equal(100, stats.CurrentLevelXp);
Assert.Equal(300, stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsNullNextLevelAtMaxConfiguredLevel()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.Level);
Assert.Equal(300, stats.CurrentLevelXp);
Assert.Null(stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsEnrollmentSlotStateAndRules()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var now = DateTime.UtcNow;
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
db.Courses.Add(new Course { Id = 1, Name = "Course" });
db.Lectures.AddRange(
Lecture(1, now.AddDays(1)),
Lecture(2, now.AddDays(2)),
Lecture(3, now.AddDays(-1)));
db.LectureEnrollments.AddRange(
new LectureEnrollment { LectureId = 1, UserId = 1 },
new LectureEnrollment { LectureId = 2, UserId = 1 },
new LectureEnrollment { LectureId = 3, UserId = 1 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.ActiveEnrollments);
Assert.Equal(5, stats.EnrollmentSlotLimit);
Assert.Equal(new[] { 1, 3, 4 }, stats.EnrollmentSlotRules.Select(rule => rule.Level));
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()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"UserServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static UserService CreateService(AppDbContext db)
{
var notifications = Substitute.For<INotificationService>();
notifications.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
var config = new ConfigurationBuilder()
.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)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Lecture Lecture(int id, DateTime startsAt) => new()
{
Id = id,
CourseId = 1,
Title = $"Lecture {id}",
StartsAt = startsAt,
EndsAt = startsAt.AddHours(2),
IsOpen = true,
MaxEnrollments = 30
};
private static User User(
int id,
string email,
string displayName,
bool isActive,
int xp,
params UserRole[] roles) =>
User(id, email, displayName, isActive, xp, DateTime.UtcNow, roles);
private static User User(
int id,
string email,
string displayName,
bool isActive,
int xp,
DateTime createdAt,
params UserRole[] roles) => new()
{
Id = id,
Email = email,
DisplayName = displayName,
IsActive = isActive,
Xp = xp,
CreatedAt = createdAt,
Roles = roles.Select(role => new UserRoleAssignment { UserId = id, Role = role }).ToList()
};
}
@@ -1,17 +0,0 @@
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.BackgroundServices;
public class AchievementCatalogHostedService : IHostedService
{
private readonly IServiceProvider _services;
public AchievementCatalogHostedService(IServiceProvider services) => _services = services;
public async Task StartAsync(CancellationToken cancellationToken)
{
await AchievementCatalogSeeder.SeedAsync(_services, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.BackgroundServices;
public class LlmProcessingBackgroundService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<LlmProcessingBackgroundService> _logger;
public LlmProcessingBackgroundService(IServiceProvider services, ILogger<LlmProcessingBackgroundService> logger)
{
_services = services; _logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("LLM Processing Background Service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
await llmService.ProcessPendingReviewsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in LLM processing background service");
}
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
}
}
@@ -1,21 +0,0 @@
using System.Threading.Channels;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.BackgroundServices;
public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue
{
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(reviewId, cancellationToken);
}
public IAsyncEnumerable<int> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
@@ -1,96 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using UniVerse.Infrastructure.Data;
namespace UniVerse.Api.BackgroundServices;
public sealed class ReviewAnalysisWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ReviewAnalysisQueue _queue;
private readonly ReviewAnalysisOptions _options;
private readonly ILogger<ReviewAnalysisWorker> _logger;
public ReviewAnalysisWorker(
IServiceProvider services,
ReviewAnalysisQueue queue,
IOptions<ReviewAnalysisOptions> options,
ILogger<ReviewAnalysisWorker> logger)
{
_services = services;
_queue = queue;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var maxConcurrency = Math.Max(1, _options.MaxConcurrentProcessing);
_logger.LogInformation(
"Review analysis worker started with max concurrency {MaxConcurrency}",
maxConcurrency);
await EnqueueExistingPendingReviewsAsync(stoppingToken);
var workers = Enumerable.Range(1, maxConcurrency)
.Select(workerNumber => ProcessQueueAsync(workerNumber, stoppingToken))
.ToArray();
try
{
await Task.WhenAll(workers);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Review analysis worker stopped");
}
}
private async Task EnqueueExistingPendingReviewsAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pendingReviewIds = await db.Reviews
.Where(r => r.LlmStatus == ReviewLlmStatus.Pending)
.OrderBy(r => r.CreatedAt)
.Select(r => r.Id)
.ToListAsync(cancellationToken);
foreach (var reviewId in pendingReviewIds)
await _queue.EnqueueAsync(reviewId, cancellationToken);
if (pendingReviewIds.Count > 0)
_logger.LogInformation(
"Queued {ReviewCount} pending reviews for immediate analysis",
pendingReviewIds.Count);
}
private async Task ProcessQueueAsync(int workerNumber, CancellationToken cancellationToken)
{
await foreach (var reviewId in _queue.ReadAllAsync(cancellationToken))
{
try
{
using var scope = _services.CreateScope();
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
await llmService.AnalyzeReviewAsync(reviewId);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Review analysis worker {WorkerNumber} failed to process review {ReviewId}",
workerNumber,
reviewId);
}
}
}
}
@@ -5,87 +5,31 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Управление определениями достижений системы геймификации.</summary>
[ApiController]
[Route("api/v1/achievements")]
[Authorize]
[Produces("application/json")]
public class AchievementsController : ControllerBase
{
private readonly IAchievementService _achievements;
public AchievementsController(IAchievementService achievements) => _achievements = achievements;
/// <summary>Получить список всех достижений.</summary>
/// <remarks>Возвращает определения достижений (без информации о получении конкретным пользователем).
/// Для достижений конкретного пользователя используйте GET /api/v1/users/{id}/achievements.</remarks>
/// <response code="200">Список достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(List<AchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll() => Ok(await _achievements.GetAllAsync());
/// <summary>Получить достижение по ID.</summary>
/// <param name="id">ID достижения.</param>
/// <response code="200">Данные достижения.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Достижение не найдено.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AchievementDto>> Get(int id) => Ok(await _achievements.GetByIdAsync(id));
/// <summary>Создать новое достижение.</summary>
/// <remarks>Только Admin. Достижения автоматически присваиваются студентам при выполнении условий.</remarks>
/// <param name="req">Название, описание, иконка, награда в XP/монетах и условие получения.</param>
/// <response code="201">Достижение создано.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AchievementDto>> Create([FromBody] CreateAchievementRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _achievements.CreateAsync(req));
/// <summary>Обновить достижение по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID достижения.</param>
/// <param name="req">Обновляемые поля достижения.</param>
/// <response code="200">Обновлённые данные достижения.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Достижение не найдено.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(AchievementDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AchievementDto>> Update(int id, [FromBody] UpdateAchievementRequest req) =>
Ok(await _achievements.UpdateAsync(id, req));
/// <summary>Удалить достижение по ID.</summary>
/// <remarks>
/// Только Admin. Удаление не отзывает достижение у уже получивших его пользователей.
/// </remarks>
/// <param name="id">ID достижения.</param>
/// <response code="204">Достижение удалено.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Достижение не найдено.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _achievements.DeleteAsync(id);
return NoContent();
}
public async Task<IActionResult> Delete(int id) { await _achievements.DeleteAsync(id); return NoContent(); }
}
@@ -1,208 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using UniVerse.Application.DTOs.Auth;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using System.Security.Cryptography;
using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Аутентификация и управление сессией пользователя.</summary>
[ApiController]
[Route("api/v1/auth")]
[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
private readonly IConfiguration _config;
public AuthController(IAuthService auth) => _auth = auth;
private const string MicrosoftStateCookieName = "msAuthState";
private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl";
public AuthController(IAuthService auth, IConfiguration config)
{
_auth = auth;
_config = config;
}
/// <summary>Вход через Microsoft Entra ID (SPA/PKCE flow).</summary>
/// <remarks>
/// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
/// полученный authorization code. В ответ возвращается пара JWT-токенов;
/// refresh token устанавливается в HttpOnly cookie.
/// </remarks>
/// <param name="request">Authorization code и redirect URI из Microsoft OAuth2.</param>
/// <response code="200">Успешный вход — возвращает access token и данные пользователя.</response>
/// <response code="400">Неверный или просроченный authorization code.</response>
[HttpPost("login/microsoft")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
{
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri, GetClientIpAddress());
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode);
SetRefreshTokenCookie(result.RefreshToken);
return Ok(result.Response);
}
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
/// <remarks>
/// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
/// и редиректит пользователя на `login.microsoftonline.com`.
/// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
/// </remarks>
/// <param name="returnUrl">URL для редиректа после успешного входа (опционально).</param>
/// <response code="302">Редирект на Microsoft authorize endpoint.</response>
/// <response code="500">Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).</response>
[HttpGet("login/microsoft")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null)
{
var tenantId = _config["AzureAd:TenantId"];
var clientId = _config["AzureAd:ClientId"];
var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
return Problem("Microsoft authentication is not configured (AzureAd:TenantId/ClientId).", statusCode: StatusCodes.Status500InternalServerError);
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
{
Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddMinutes(10)
});
}
var authorizeEndpoint = $"{instance.TrimEnd('/')}/{tenantId}/oauth2/v2.0/authorize";
var scope = _config["AzureAd:Scopes"] ?? "openid profile email offline_access User.Read";
var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary<string, string?>
{
["client_id"] = clientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri,
["response_mode"] = "query",
["scope"] = scope,
["state"] = state
});
return Redirect(authorizeUrl);
}
/// <summary>OAuth2 callback — обмен code на токены (server-driven flow).</summary>
/// <remarks>
/// Microsoft редиректит браузер сюда после успешного входа.
/// Backend валидирует CSRF state, обменивает code на токены,
/// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
/// </remarks>
/// <param name="code">Authorization code от Microsoft.</param>
/// <param name="state">CSRF state для верификации.</param>
/// <param name="error">Код ошибки от Microsoft (если вход не удался).</param>
/// <param name="errorDescription">Описание ошибки от Microsoft.</param>
/// <response code="302">Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.</response>
/// <response code="200">Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).</response>
/// <response code="400">Отсутствует authorization code.</response>
/// <response code="401">Ошибка от Microsoft или невалидный CSRF state.</response>
[HttpGet("callback/microsoft")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> CallbackMicrosoft(
[FromQuery] string? code = null,
[FromQuery] string? state = null,
[FromQuery] string? error = null,
[FromQuery(Name = "error_description")] string? errorDescription = null)
{
if (!string.IsNullOrEmpty(error))
{
return Unauthorized(new
{
error,
errorDescription
});
}
if (string.IsNullOrWhiteSpace(code))
return BadRequest(new { error = "missing_code" });
var expectedState = Request.Cookies[MicrosoftStateCookieName];
if (string.IsNullOrWhiteSpace(expectedState) || string.IsNullOrWhiteSpace(state) || !string.Equals(expectedState, state, StringComparison.Ordinal))
return Unauthorized(new { error = "invalid_state" });
Response.Cookies.Delete(MicrosoftStateCookieName);
var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft");
var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken);
var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"];
Response.Cookies.Delete(MicrosoftReturnUrlCookieName);
if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl))
{
// Put access token in URL fragment so it is not sent as Referer to the backend.
// Frontend can read it from location.hash on the landing page.
var fragment = $"access_token={Uri.EscapeDataString(result.Response.AccessToken)}&expires_at={Uri.EscapeDataString(result.Response.ExpiresAt.ToString("O"))}";
return Redirect($"{returnUrl}#{fragment}");
}
// Useful for manual testing without frontend: you'll see JSON in the browser.
return Ok(result.Response);
}
/// <summary>Dev-only вход без OAuth (только в Development-окружении).</summary>
/// <remarks>
/// Создаёт или находит пользователя по email без реального OAuth flow.
/// Возвращает 404 в Production и Staging.
/// </remarks>
/// <param name="request">Email, отображаемое имя и роль тестового пользователя.</param>
/// <response code="200">Успешный вход.</response>
/// <response code="404">Endpoint недоступен вне Development.</response>
[HttpPost("login/dev")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
{
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
return NotFound();
var roles = request.Roles?.Count > 0 ? request.Roles : [UserRole.Student];
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, roles, GetClientIpAddress());
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role);
SetRefreshTokenCookie(result.RefreshToken);
return Ok(result.Response);
}
/// <summary>Обновление access token по refresh token из HttpOnly cookie.</summary>
/// <remarks>
/// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
/// Возвращает новую пару токенов и обновляет cookie.
/// </remarks>
/// <response code="200">Новая пара токенов.</response>
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
[HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthResponse>> Refresh()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -212,17 +41,8 @@ public class AuthController : ControllerBase
return Ok(result.Response);
}
/// <summary>Выход из системы — отзыв refresh token.</summary>
/// <remarks>
/// Инвалидирует текущий refresh token в БД и удаляет cookie.
/// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
/// </remarks>
/// <response code="204">Выход выполнен успешно.</response>
/// <response code="401">Требуется аутентификация.</response>
[Authorize]
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Logout()
{
var refreshToken = Request.Cookies["refreshToken"];
@@ -232,16 +52,8 @@ public class AuthController : ControllerBase
return NoContent();
}
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
/// <response code="200">Данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
[Authorize]
[HttpGet("me")]
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Me()
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -250,21 +62,6 @@ public class AuthController : ControllerBase
return Ok(user);
}
private string? GetClientIpAddress()
{
if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var firstForwardedAddress = forwardedFor.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(firstForwardedAddress))
return firstForwardedAddress;
}
if (Request.Headers.TryGetValue("X-Real-IP", out var realIp) && !string.IsNullOrWhiteSpace(realIp))
return realIp;
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private void SetRefreshTokenCookie(string token)
{
Response.Cookies.Append("refreshToken", token, new CookieOptions
@@ -273,32 +70,4 @@ public class AuthController : ControllerBase
Expires = DateTime.UtcNow.AddDays(30)
});
}
private string BuildAbsoluteUrl(string path)
{
if (!path.StartsWith('/')) path = "/" + path;
return $"{Request.Scheme}://{Request.Host}{path}";
}
private bool IsAllowedReturnUrl(string returnUrl)
{
if (Uri.TryCreate(returnUrl, UriKind.Relative, out _))
return true;
if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute))
return false;
var allowedOrigins = _config.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
foreach (var origin in allowedOrigins)
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var allowed))
continue;
if (string.Equals(allowed.Scheme, absolute.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(allowed.Host, absolute.Host, StringComparison.OrdinalIgnoreCase)
&& allowed.Port == absolute.Port)
return true;
}
return false;
}
}
@@ -1,132 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Courses;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Управление курсами (дисциплинами) и их тегами.</summary>
[ApiController]
[Route("api/v1/courses")]
[Authorize]
[Produces("application/json")]
public class CoursesController : ControllerBase
{
private readonly ICourseService _courses;
public CoursesController(ICourseService courses) => _courses = courses;
/// <summary>Получить список курсов с фильтрацией и пагинацией.</summary>
/// <param name="filter">Фильтры: tagId, search, isSynced; параметры пагинации.</param>
/// <response code="200">Список курсов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<CourseDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll([FromQuery] CourseFilterRequest filter) =>
Ok(await _courses.GetAllAsync(filter));
/// <summary>Получить курс по ID (включая теги).</summary>
/// <param name="id">ID курса.</param>
/// <response code="200">Данные курса с тегами.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Курс не найден.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CourseDto>> Get(int id) => Ok(await _courses.GetByIdAsync(id));
/// <summary>Создать новый курс.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="req">Название и описание курса.</param>
/// <response code="201">Курс создан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<CourseDto>> Create([FromBody] CreateCourseRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _courses.CreateAsync(req));
/// <summary>Обновить курс по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID курса.</param>
/// <param name="req">Новое название и/или описание.</param>
/// <response code="200">Обновлённые данные курса.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(CourseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CourseDto>> Update(int id, [FromBody] UpdateCourseRequest req) =>
Ok(await _courses.UpdateAsync(id, req));
/// <summary>Удалить курс по ID.</summary>
/// <remarks>Только Admin. Удаление курса каскадно удаляет связанные лекции.</remarks>
/// <param name="id">ID курса.</param>
/// <response code="204">Курс удалён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс не найден.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _courses.DeleteAsync(id);
return NoContent();
}
public async Task<IActionResult> Delete(int id) { await _courses.DeleteAsync(id); return NoContent(); }
/// <summary>Привязать тег к курсу.</summary>
/// <remarks>Только Admin. Тег должен существовать в системе.</remarks>
/// <param name="id">ID курса.</param>
/// <param name="tagId">ID тега.</param>
/// <response code="204">Тег привязан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс или тег не найден.</response>
/// <response code="409">Тег уже привязан к курсу.</response>
[Authorize(Roles = "Admin")]
[HttpPost("{id:int}/tags")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> AddTag(int id, [FromBody] int tagId)
{
await _courses.AddTagAsync(id, tagId);
return NoContent();
}
{ await _courses.AddTagAsync(id, tagId); return NoContent(); }
/// <summary>Отвязать тег от курса.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID курса.</param>
/// <param name="tagId">ID тега.</param>
/// <response code="204">Тег отвязан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Курс или тег не найден, либо связь не существует.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}/tags/{tagId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoveTag(int id, int tagId)
{
await _courses.RemoveTagAsync(id, tagId);
return NoContent();
}
{ await _courses.RemoveTagAsync(id, tagId); return NoContent(); }
}
@@ -7,203 +7,59 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Каталог лекций — просмотр, управление, запись и отзывы.</summary>
[ApiController]
[Route("api/v1/lectures")]
[Authorize]
[Produces("application/json")]
public class LecturesController : ControllerBase
{
private readonly ILectureService _lectures;
private readonly IReviewService _reviews;
public LecturesController(ILectureService lectures, IReviewService reviews)
{
_lectures = lectures;
_reviews = reviews;
}
{ _lectures = lectures; _reviews = reviews; }
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
private int CurrentUserId => int.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
private bool CurrentUserIsAdmin => User.IsInRole("Admin");
/// <summary>Получить каталог лекций с фильтрацией и пагинацией.</summary>
/// <param name="filter">
/// Фильтры: dateFrom, dateTo, courseId, teacherId, format (Online/Offline),
/// isOpen, tagId, search; параметры пагинации.
/// </param>
/// <remarks>Включает флаг `isEnrolled` — записан ли текущий пользователь на лекцию.</remarks>
/// <response code="200">Список лекций (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<LectureDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll([FromQuery] LectureFilterRequest filter) =>
Ok(await _lectures.GetAllAsync(filter, CurrentUserId));
Ok(await _lectures.GetAllAsync(filter));
/// <summary>Получить детальную карточку лекции по ID.</summary>
/// <remarks>
/// Включает флаг `isEnrolled` — записан ли текущий пользователь на эту лекцию.
/// </remarks>
/// <param name="id">ID лекции.</param>
/// <response code="200">Детальные данные лекции.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Лекция не найдена.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(LectureDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Get(int id) =>
Ok(await _lectures.GetByIdAsync(id, CurrentUserId));
/// <summary>Создать новую лекцию.</summary>
/// <remarks>Только Admin. Курс задаётся при создании и не может быть изменён.</remarks>
/// <param name="req">Данные лекции: курс, преподаватель, локация, время, формат, вместимость.</param>
/// <response code="201">Лекция создана.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<LectureDto>> Create([FromBody] CreateLectureRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _lectures.CreateAsync(req));
/// <summary>Обновить лекцию по ID.</summary>
/// <remarks>Admin или Teacher. CourseId изменить нельзя.</remarks>
/// <param name="id">ID лекции.</param>
/// <param name="req">Обновляемые поля: преподаватель, локация, время, формат, описание.</param>
/// <response code="200">Обновлённые данные лекции.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(LectureDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LectureDto>> Update(int id, [FromBody] UpdateLectureRequest req) =>
Ok(await _lectures.UpdateAsync(id, req, CurrentUserId, CurrentUserIsAdmin));
Ok(await _lectures.UpdateAsync(id, req));
/// <summary>Удалить лекцию по ID.</summary>
/// <remarks>Только Admin. Каскадно удаляет записи и отзывы.</remarks>
/// <param name="id">ID лекции.</param>
/// <response code="204">Лекция удалена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _lectures.DeleteAsync(id);
return NoContent();
}
public async Task<IActionResult> Delete(int id) { await _lectures.DeleteAsync(id); return NoContent(); }
/// <summary>Записаться на лекцию.</summary>
/// <remarks>
/// Только Student. Проверяет наличие свободных мест и отсутствие повторной записи.
/// После посещения начисляются монеты через gamification.
/// </remarks>
/// <param name="id">ID лекции.</param>
/// <response code="204">Запись выполнена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Student.</response>
/// <response code="404">Лекция не найдена.</response>
/// <response code="409">Студент уже записан или мест нет.</response>
[Authorize(Roles = "Student")]
[HttpPost("{id:int}/enroll")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Enroll(int id)
{
await _lectures.EnrollAsync(id, CurrentUserId);
return NoContent();
}
public async Task<IActionResult> Enroll(int id) { await _lectures.EnrollAsync(id, CurrentUserId); return NoContent(); }
/// <summary>Отменить запись на лекцию.</summary>
/// <remarks>Только Student. Отменить можно только свою запись.</remarks>
/// <param name="id">ID лекции.</param>
/// <response code="204">Запись отменена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Student.</response>
/// <response code="404">Лекция или запись не найдена.</response>
[Authorize(Roles = "Student")]
[HttpDelete("{id:int}/enroll")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Unenroll(int id)
{
await _lectures.UnenrollAsync(id, CurrentUserId);
return NoContent();
}
public async Task<IActionResult> Unenroll(int id) { await _lectures.UnenrollAsync(id, CurrentUserId); return NoContent(); }
/// <summary>Отметить посещение студента на лекции.</summary>
/// <remarks>
/// Admin или Teacher. При отметке `attended=true` начисляются монеты за посещение
/// через gamification service.
/// </remarks>
/// <param name="id">ID лекции.</param>
/// <param name="userId">ID студента.</param>
/// <param name="attended">true — посетил, false — не посетил.</param>
/// <response code="204">Посещение отмечено.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция или запись студента не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpPatch("{id:int}/attendance/{userId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Attendance(int id, int userId, [FromBody] bool attended)
{
await _lectures.MarkAttendanceAsync(id, userId, attended, CurrentUserId, CurrentUserIsAdmin);
return NoContent();
}
{ await _lectures.MarkAttendanceAsync(id, userId, attended); return NoContent(); }
/// <summary>Получить список записавшихся студентов на лекцию.</summary>
/// <remarks>Только Admin или Teacher. Включает флаг посещения (`attended`).</remarks>
/// <param name="id">ID лекции.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}/enrollments")]
[ProducesResponseType(typeof(PagedResult<EnrollmentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _lectures.GetEnrollmentsAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
Ok(await _lectures.GetEnrollmentsAsync(id, pagination));
/// <summary>Получить отзывы к лекции.</summary>
/// <remarks>Только Admin или Teacher.</remarks>
/// <param name="id">ID лекции.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Лекция не найдена.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByLectureAsync(id, pagination, CurrentUserId, CurrentUserIsAdmin));
Ok(await _reviews.GetByLectureAsync(id, pagination));
}
@@ -5,85 +5,31 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Управление локациями проведения лекций (аудитории, онлайн-площадки).</summary>
[ApiController]
[Route("api/v1/locations")]
[Authorize]
[Produces("application/json")]
public class LocationsController : ControllerBase
{
private readonly ILocationService _locations;
public LocationsController(ILocationService locations) => _locations = locations;
/// <summary>Получить список всех локаций.</summary>
/// <response code="200">Список локаций.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(List<LocationDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll() => Ok(await _locations.GetAllAsync());
/// <summary>Получить локацию по ID.</summary>
/// <param name="id">ID локации.</param>
/// <response code="200">Данные локации.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Локация не найдена.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LocationDto>> Get(int id) => Ok(await _locations.GetByIdAsync(id));
/// <summary>Создать новую локацию.</summary>
/// <remarks>Только Admin. Локации также создаются автоматически при синхронизации с Modeus.</remarks>
/// <param name="req">Название, корпус, аудитория и/или адрес.</param>
/// <response code="201">Локация создана.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<LocationDto>> Create([FromBody] CreateLocationRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _locations.CreateAsync(req));
/// <summary>Обновить локацию по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID локации.</param>
/// <param name="req">Обновляемые поля: название, корпус, аудитория, адрес.</param>
/// <response code="200">Обновлённые данные локации.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Локация не найдена.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(LocationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LocationDto>> Update(int id, [FromBody] UpdateLocationRequest req) =>
Ok(await _locations.UpdateAsync(id, req));
/// <summary>Удалить локацию по ID.</summary>
/// <remarks>
/// Только Admin. При удалении локации у связанных лекций поле `locationId` становится null.
/// </remarks>
/// <param name="id">ID локации.</param>
/// <response code="204">Локация удалена.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Локация не найдена.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _locations.DeleteAsync(id);
return NoContent();
}
public async Task<IActionResult> Delete(int id) { await _locations.DeleteAsync(id); return NoContent(); }
}
@@ -1,94 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Отправка и планирование уведомлений через доступные каналы.</summary>
[ApiController]
[Route("api/v1/notifications")]
[Authorize]
[Produces("application/json")]
public class NotificationsController : ControllerBase
{
private readonly INotificationService _notifications;
public NotificationsController(INotificationService notifications)
{
_notifications = notifications;
}
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
/// <summary>Получить уведомления текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <param name="cancellationToken">Токен отмены запроса.</param>
/// <response code="200">Список уведомлений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<UserNotificationDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<PagedResult<UserNotificationDto>>> GetMine(
[FromQuery] PaginationRequest pagination,
CancellationToken cancellationToken) =>
Ok(await _notifications.GetUserNotificationsAsync(CurrentUserId, pagination, cancellationToken));
/// <summary>Отметить все уведомления текущего пользователя как прочитанные.</summary>
/// <param name="cancellationToken">Токен отмены запроса.</param>
/// <response code="204">Уведомления отмечены прочитанными.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpPatch("read-all")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> MarkAllRead(CancellationToken cancellationToken)
{
await _notifications.MarkAllReadAsync(CurrentUserId, cancellationToken);
return NoContent();
}
/// <summary>Отправить уведомление немедленно.</summary>
/// <remarks>
/// Канал задаётся строкой, например `email`. Новые провайдеры добавляются через `INotificationProvider`.
/// </remarks>
/// <param name="request">Канал, получатель, тема и текст уведомления.</param>
/// <response code="202">Уведомление принято к отправке.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost("send")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Send([FromBody] SendNotificationRequest request, CancellationToken cancellationToken)
{
var message = new NotificationMessage(
request.Channel,
request.Recipient,
request.Subject,
request.Body,
request.RecipientName,
request.Metadata);
await _notifications.SendAsync(message, cancellationToken);
return Accepted();
}
/// <summary>Запланировать отложенную отправку уведомления через Quartz.NET.</summary>
/// <param name="request">Уведомление и момент отправки.</param>
/// <response code="202">Уведомление поставлено в очередь Quartz.NET.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost("schedule")]
[ProducesResponseType(typeof(ScheduledNotificationResponse), StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ScheduledNotificationResponse>> Schedule([FromBody] ScheduleNotificationRequest request, CancellationToken cancellationToken)
{
var response = await _notifications.ScheduleAsync(request, cancellationToken);
return Accepted(response);
}
}
@@ -7,161 +7,40 @@ using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Отзывы студентов на лекции с LLM-анализом и модерацией.</summary>
[ApiController]
[Route("api/v1/reviews")]
[Authorize]
[Produces("application/json")]
public class ReviewsController : ControllerBase
{
private readonly IReviewService _reviews;
private readonly IReviewPromptService _reviewPrompts;
public ReviewsController(IReviewService reviews) => _reviews = reviews;
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
public ReviewsController(IReviewService reviews, IReviewPromptService reviewPrompts)
{
_reviews = reviews;
_reviewPrompts = reviewPrompts;
}
private int CurrentUserId => int.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
/// <summary>Создать отзыв к лекции.</summary>
/// <remarks>
/// Только Student. После создания отзыв отправляется на LLM-анализ
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
/// скрытно от пользователя.
/// </remarks>
/// <param name="req">ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.</param>
/// <response code="201">Отзыв создан и поставлен в очередь на LLM-анализ.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Student.</response>
/// <response code="404">Лекция не найдена.</response>
/// <response code="409">Студент уже оставил отзыв к этой лекции.</response>
[Authorize(Roles = "Student")]
[HttpPost]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ReviewDto>> Create([FromBody] CreateReviewRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _reviews.CreateAsync(CurrentUserId, req));
/// <summary>Получить список всех отзывов.</summary>
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
/// <param name="filter">Параметры фильтрации и пагинации.</param>
/// <response code="200">Список всех отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet]
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> List([FromQuery] ReviewFilterRequest filter) =>
Ok(await _reviews.GetAllAsync(filter));
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
/// <response code="200">Текущий шаблон промпта.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> GetLlmPrompt() =>
Ok(await _reviewPrompts.GetAsync());
/// <summary>Обновить промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Промпт применяется к следующим анализам и ручным повторам.</remarks>
/// <param name="request">Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}.</param>
/// <response code="200">Сохранённый шаблон промпта.</response>
/// <response code="400">Промпт пустой или не содержит обязательные плейсхолдеры.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPut("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> UpdateLlmPrompt([FromBody] UpdateReviewPromptRequest request) =>
Ok(await _reviewPrompts.UpdateAsync(request));
/// <summary>Получить отзыв по ID.</summary>
/// <remarks>Только Admin или Teacher.</remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="200">Данные отзыва (включая LLM-статус и сентимент).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin или Teacher.</response>
/// <response code="404">Отзыв не найден.</response>
[Authorize(Roles = "Admin,Teacher")]
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ReviewDto>> Get(int id) => Ok(await _reviews.GetByIdAsync(id));
/// <summary>Обновить отзыв.</summary>
/// <remarks>
/// Разрешено любому авторизованному пользователю, но сервис проверяет владельца.
/// Изменение текста сбрасывает LLM-статус в `Pending` (повторный анализ).
/// </remarks>
/// <param name="id">ID отзыва.</param>
/// <param name="req">Новая оценка и/или текст.</param>
/// <response code="200">Обновлённые данные отзыва.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Отзыв принадлежит другому пользователю.</response>
/// <response code="404">Отзыв не найден.</response>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ReviewDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ReviewDto>> Update(int id, [FromBody] UpdateReviewRequest req) =>
Ok(await _reviews.UpdateAsync(id, CurrentUserId, req));
/// <summary>Удалить отзыв.</summary>
/// <remarks>Владелец может удалить свой отзыв. Admin может удалить любой.</remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="204">Отзыв удалён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Нет прав на удаление (не владелец и не Admin).</response>
/// <response code="404">Отзыв не найден.</response>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _reviews.DeleteAsync(id, CurrentUserId, User.IsInRole("Admin"));
return NoContent();
}
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
/// <remarks>
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его
/// на повторную обработку.
/// </remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="204">Повторный анализ запланирован.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Отзыв не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("pending")]
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetPendingAsync(pagination));
[Authorize(Roles = "Admin")]
[HttpPost("{id:int}/reanalyze")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Reanalyze(int id)
{
await _reviews.ReanalyzeAsync(id);
return NoContent();
}
public async Task<IActionResult> Reanalyze(int id) { await _reviews.ReanalyzeAsync(id); return NoContent(); }
}
@@ -5,75 +5,28 @@ using UniVerse.Application.Interfaces;
namespace UniVerse.Api.Controllers;
/// <summary>Синхронизация данных из внешней системы расписания Modeus (только Admin).</summary>
[ApiController]
[Route("api/v1/sync")]
[Authorize(Roles = "Admin")]
[Produces("application/json")]
public class SyncController : ControllerBase
{
private readonly IScheduleSyncService _sync;
public SyncController(IScheduleSyncService sync) => _sync = sync;
/// <summary>Запустить синхронизацию расписания лекций из Modeus.</summary>
/// <remarks>
/// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных
/// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду,
/// размеру выборки, аудиториям, участникам, реализациям курсов/циклов,
/// специальностям, годам набора, профилям, учебным планам и типам занятий.
/// </remarks>
/// <param name="req">Параметры поиска событий во внешнем сервисе расписания.</param>
/// <response code="200">Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("schedule")]
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<SyncResultDto>> SyncSchedule([FromBody] SyncScheduleRequest req) =>
Ok(await _sync.SyncScheduleAsync(req));
/// <summary>Получить статус последней синхронизации.</summary>
/// <remarks>Только Admin. Возвращает время и результат последней успешной синхронизации.</remarks>
/// <response code="200">Статус синхронизации.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpGet("status")]
[ProducesResponseType(typeof(SyncStatusDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<SyncStatusDto>> Status() =>
Ok(await _sync.GetLastSyncStatusAsync());
/// <summary>Синхронизировать аудитории (локации) из Modeus.</summary>
/// <remarks>
/// Только Admin. Импортирует аудитории из `schedule.rdcenter.ru` и создаёт
/// соответствующие записи в таблице locations.
/// </remarks>
/// <response code="200">Результат синхронизации аудиторий.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("rooms")]
[ProducesResponseType(typeof(SyncResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<SyncResultDto>> SyncRooms() =>
Ok(await _sync.SyncRoomsAsync());
/// <summary>Поиск преподавателей в Modeus по ФИО.</summary>
/// <remarks>
/// Только Admin. Ищет преподавателей через внешнее API и возвращает список
/// для ручного импорта. Найденные преподаватели не создаются автоматически.
/// </remarks>
/// <param name="fullname">Полное имя или часть имени преподавателя для поиска.</param>
/// <response code="200">Список найденных преподавателей.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[HttpPost("employees")]
[ProducesResponseType(typeof(List<EmployeeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> SearchEmployees([FromQuery] string fullname) =>
Ok(await _sync.SearchEmployeesAsync(fullname));
}
@@ -6,101 +6,35 @@ using UniVerse.Domain.Enums;
namespace UniVerse.Api.Controllers;
/// <summary>Управление тегами для категоризации курсов (институты, факультеты, темы и др.).</summary>
[ApiController]
[Route("api/v1/tags")]
[Authorize]
[Produces("application/json")]
public class TagsController : ControllerBase
{
private readonly ITagService _tags;
public TagsController(ITagService tags) => _tags = tags;
/// <summary>Получить список тегов с опциональной фильтрацией по типу и родителю.</summary>
/// <param name="type">Тип тега: Institute, Faculty, Subject, Organization, Topic, Other.</param>
/// <param name="parentId">ID родительского тега (фильтрация дочерних).</param>
/// <response code="200">Список тегов.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet]
[ProducesResponseType(typeof(List<TagDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetAll([FromQuery] TagType? type, [FromQuery] int? parentId) =>
Ok(await _tags.GetAllAsync(type, parentId));
/// <summary>Получить тег по ID.</summary>
/// <param name="id">ID тега.</param>
/// <response code="200">Данные тега.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Тег не найден.</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TagDto>> Get(int id) => Ok(await _tags.GetByIdAsync(id));
/// <summary>Получить иерархическое дерево всех тегов.</summary>
/// <remarks>
/// Возвращает корневые теги с вложенными дочерними тегами.
/// Полезно для построения фильтрующих UI-компонентов.
/// </remarks>
/// <response code="200">Иерархический список тегов.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("tree")]
[ProducesResponseType(typeof(List<TagTreeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> GetTree() => Ok(await _tags.GetTreeAsync());
/// <summary>Создать новый тег.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="req">Название, тип и опциональный родительский тег.</param>
/// <response code="201">Тег создан.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPost]
[ProducesResponseType(typeof(TagDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<TagDto>> Create([FromBody] CreateTagRequest req) =>
CreatedAtAction(nameof(Get), new { id = 0 }, await _tags.CreateAsync(req));
/// <summary>Обновить тег по ID.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="id">ID тега.</param>
/// <param name="req">Новое название, тип и/или родительский тег.</param>
/// <response code="200">Обновлённые данные тега.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Тег не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(TagDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TagDto>> Update(int id, [FromBody] UpdateTagRequest req) =>
Ok(await _tags.UpdateAsync(id, req));
/// <summary>Удалить тег по ID.</summary>
/// <remarks>
/// Только Admin. Удаление тега каскадно удаляет привязки к курсам (`course_tags`).
/// Дочерние теги остаются, но их `parentId` становится null.
/// </remarks>
/// <param name="id">ID тега.</param>
/// <response code="204">Тег удалён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Тег не найден.</response>
[Authorize(Roles = "Admin")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
await _tags.DeleteAsync(id);
return NoContent();
}
public async Task<IActionResult> Delete(int id) { await _tags.DeleteAsync(id); return NoContent(); }
}
@@ -5,332 +5,74 @@ using UniVerse.Application.DTOs.Users;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using System.Security.Claims;
using System.Text;
namespace UniVerse.Api.Controllers;
/// <summary>Управление пользователями, профилями и геймификацией.</summary>
[ApiController]
[Route("api/v1/users")]
[Authorize]
[Produces("application/json")]
public class UsersController : ControllerBase
{
private readonly IUserService _users;
private readonly IReviewService _reviews;
private readonly IGamificationService _gamification;
public UsersController(IUserService users, IReviewService reviews, IGamificationService gamification)
{
_users = users; _reviews = reviews; _gamification = gamification;
}
private int CurrentUserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
private static CurrentUserDto ToCurrentUserDto(UserDto user) => new(
user.Id,
user.Email,
user.DisplayName,
user.AvatarUrl,
user.Roles,
user.Xp,
user.Coins,
user.Level,
user.CreatedAt);
/// <summary>Получить профиль текущего пользователя.</summary>
/// <response code="200">Данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me")]
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CurrentUserDto>> GetMe() =>
Ok(ToCurrentUserDto(await _users.GetByIdAsync(CurrentUserId)));
/// <summary>Обновить профиль текущего пользователя (displayName, avatarUrl).</summary>
/// <param name="req">Обновляемые поля профиля.</param>
/// <response code="200">Обновлённые данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpPut("me")]
[ProducesResponseType(typeof(CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CurrentUserDto>> UpdateMe([FromBody] UpdateUserRequest req) =>
Ok(ToCurrentUserDto(await _users.UpdateProfileAsync(CurrentUserId, req)));
/// <summary>Получить статистику текущего пользователя.</summary>
/// <response code="200">Статистика текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me/stats")]
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserStatsDto>> MyStats() =>
Ok(await _users.GetStatsAsync(CurrentUserId));
/// <summary>Получить список записей текущего пользователя на лекции.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден.</response>
[HttpGet("me/enrollments")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> MyEnrollments([FromQuery] PaginationRequest 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>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyReviews([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByUserAsync(CurrentUserId, pagination));
/// <summary>Получить достижения текущего пользователя.</summary>
/// <response code="200">Список полученных достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/achievements")]
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyAchievements() =>
Ok(await _gamification.GetUserAchievementsAsync(CurrentUserId));
/// <summary>Получить историю транзакций монет текущего пользователя.</summary>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">История транзакций (пагинированная).</response>
/// <response code="401">Требуется аутентификация.</response>
[HttpGet("me/transactions")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> MyTransactions([FromQuery] PaginationRequest pagination) =>
Ok(await _gamification.GetTransactionsAsync(CurrentUserId, pagination));
/// <summary>Получить профиль пользователя по ID.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Данные пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserDto>> Get(int id) => Ok(await _users.GetByIdAsync(id));
/// <summary>Обновить профиль пользователя (displayName, avatarUrl).</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте PUT /api/v1/users/me.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="req">Обновляемые поля профиля.</param>
/// <response code="200">Обновлённые данные пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req) =>
Ok(await _users.UpdateProfileAsync(id, req));
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserRequest req)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
return Ok(await _users.UpdateProfileAsync(id, req));
}
/// <summary>Получить статистику пользователя (XP, монеты, уровень, посещения).</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/stats.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Статистика пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/stats")]
[ProducesResponseType(typeof(UserStatsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
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>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/enrollments.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список записей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/enrollments")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Lectures.LectureDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _users.GetEnrollmentsAsync(id, pagination));
public async Task<ActionResult> Enrollments(int id, [FromQuery] PaginationRequest pagination)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
// Delegate to lecture service would be more proper, but returning reviews for now
return Ok();
}
/// <summary>Получить отзывы пользователя.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/reviews.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/reviews")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Reviews.ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Reviews(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetByUserAsync(id, pagination));
/// <summary>Получить достижения пользователя.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/achievements.</remarks>
/// <param name="id">ID пользователя.</param>
/// <response code="200">Список полученных достижений.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/achievements")]
[ProducesResponseType(typeof(List<UniVerse.Application.DTOs.Achievements.UserAchievementDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Achievements(int id) =>
Ok(await _gamification.GetUserAchievementsAsync(id));
/// <summary>Получить историю транзакций монет пользователя.</summary>
/// <remarks>Только Admin. Для текущего пользователя используйте GET /api/v1/users/me/transactions.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">История транзакций (пагинированная).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("{id:int}/transactions")]
[ProducesResponseType(typeof(PagedResult<UniVerse.Application.DTOs.Gamification.CoinTransactionDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination) =>
Ok(await _gamification.GetTransactionsAsync(id, pagination));
public async Task<ActionResult> Transactions(int id, [FromQuery] PaginationRequest pagination)
{
if (CurrentUserId != id && !User.IsInRole("Admin")) return Forbid();
return Ok(await _gamification.GetTransactionsAsync(id, pagination));
}
/// <summary>Получить список всех пользователей с фильтрацией и пагинацией.</summary>
/// <remarks>Только Admin.</remarks>
/// <param name="filter">Параметры фильтрации (поиск, роль, активность) и пагинации.</param>
/// <response code="200">Список пользователей (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet]
[ProducesResponseType(typeof(PagedResult<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> GetAll([FromQuery] UserFilterRequest filter) =>
Ok(await _users.GetAllAsync(filter));
/// <summary>Изменить набор ролей пользователя.</summary>
/// <remarks>Только Admin. Доступные роли: Student, Teacher, Admin.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="roles">Новый набор ролей пользователя.</param>
/// <response code="204">Роли успешно изменены.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPatch("{id:int}/role")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetRole(int id, [FromBody] IReadOnlyCollection<UserRole> roles)
public async Task<IActionResult> SetRole(int id, [FromBody] UserRole role)
{
if (roles.Count == 0)
return BadRequest("At least one role is required.");
await _users.SetRolesAsync(id, roles);
await _users.SetRoleAsync(id, role);
return NoContent();
}
/// <summary>Активировать или деактивировать аккаунт пользователя.</summary>
/// <remarks>Только Admin. Деактивированный пользователь не может войти в систему.</remarks>
/// <param name="id">ID пользователя.</param>
/// <param name="isActive">true — активировать, false — деактивировать.</param>
/// <response code="204">Статус успешно изменён.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
/// <response code="404">Пользователь не найден.</response>
[Authorize(Roles = "Admin")]
[HttpPatch("{id:int}/active")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
{
await _users.SetActiveAsync(id, isActive);
@@ -1,81 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace UniVerse.Api.Filters;
/// <summary>
/// Swagger operation filter that:
/// 1. Adds Bearer security requirement only to endpoints that actually require authentication.
/// 2. Appends a "Required roles: ..." remark to the operation description when role restrictions exist.
///
/// This replaces the global AddSecurityRequirement approach so anonymous endpoints
/// (auth/login, auth/refresh, auth/callback) don't show the lock icon in Swagger UI.
/// </summary>
public class AuthorizeOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Collect [Authorize] and [AllowAnonymous] from both the controller and the action.
var actionAttributes = context.MethodInfo.GetCustomAttributes(inherit: true);
var controllerAttributes = context.MethodInfo.DeclaringType?
.GetCustomAttributes(inherit: true) ?? [];
var allAttributes = actionAttributes.Concat(controllerAttributes).ToList();
var hasAllowAnonymous = allAttributes.OfType<AllowAnonymousAttribute>().Any();
if (hasAllowAnonymous)
return; // completely public — no lock icon
var authorizeAttributes = allAttributes.OfType<AuthorizeAttribute>().ToList();
if (authorizeAttributes.Count == 0)
return; // no [Authorize] at all — also public
// Collect all distinct roles across all [Authorize(Roles = "...")] attributes.
var roles = authorizeAttributes
.Where(a => !string.IsNullOrWhiteSpace(a.Roles))
.SelectMany(a => a.Roles!.Split(',', StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(r => r)
.ToList();
// Append role information to the operation description.
var roleInfo = roles.Count > 0
? $"**Required roles:** {string.Join(", ", roles)}"
: "**Required:** any authenticated user";
operation.Description = string.IsNullOrWhiteSpace(operation.Description)
? roleInfo
: $"{operation.Description}\n\n{roleInfo}";
operation.Responses ??= new OpenApiResponses();
// Add 401 / 403 responses if not already declared.
if (!operation.Responses.ContainsKey("401"))
{
operation.Responses.Add("401", new OpenApiResponse
{
Description = "Unauthorized — JWT token missing or invalid"
});
}
if (roles.Count > 0 && !operation.Responses.ContainsKey("403"))
{
operation.Responses.Add("403", new OpenApiResponse
{
Description = $"Forbidden — requires role: {string.Join(" or ", roles)}"
});
}
// Add Bearer security requirement to this specific operation.
// OpenAPI v2 (Microsoft.OpenApi 2.x) uses OpenApiSecuritySchemeReference
// instead of OpenApiSecurityScheme with a Reference property.
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", context.Document);
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
[bearerSchemeRef] = []
});
}
}
@@ -24,7 +24,6 @@ public class ExceptionHandlingMiddleware
{
var (statusCode, title) = exception switch
{
BadRequestException => ((int)HttpStatusCode.BadRequest, "Bad Request"),
NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"),
ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"),
ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"),
@@ -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,8 +0,0 @@
namespace UniVerse.Api.Options;
public class ReviewAnalysisOptions
{
public const string SectionName = "Llm:ReviewAnalysis";
public int MaxConcurrentProcessing { get; set; } = 1;
}
+16 -159
View File
@@ -1,37 +1,19 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Prometheus;
using Quartz;
using Serilog;
using System.Threading.RateLimiting;
using UniVerse.Api.BackgroundServices;
using UniVerse.Api.Filters;
using UniVerse.Api.Middleware;
using UniVerse.Api.Options;
using UniVerse.Application.Interfaces;
using UniVerse.Infrastructure.Services;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.ExternalServices;
using UniVerse.Infrastructure.Notifications;
var builder = WebApplication.CreateBuilder(args);
var useAspire = builder.Configuration.GetValue<bool>("Aspire:Enabled");
var isOpenApiGeneration = AppDomain.CurrentDomain.GetAssemblies()
.Any(assembly => assembly.GetName().Name == "GetDocument.Insider");
if (useAspire)
{
builder.AddServiceDefaults();
}
// --- Serilog ---
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
@@ -47,7 +29,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
npgsql =>
{
npgsql.EnableRetryOnFailure(3);
npgsql.MigrationsAssembly("UniVerse.Infrastructure"); // Указывает EF Core, в какой сборке искать/хранить миграции.
npgsql.MigrationsAssembly("UniVerse.Infrastructure");
});
});
@@ -68,55 +50,11 @@ builder.Services.AddAuthentication(options =>
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"] ?? "default-dev-secret-key-change-in-production-32chars!!"))
};
});
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 ---
builder.Services.AddCors(options =>
{
@@ -139,40 +77,10 @@ builder.Services.AddScoped<ILocationService, LocationService>();
builder.Services.AddScoped<ICourseService, CourseService>();
builder.Services.AddScoped<ILectureService, LectureService>();
builder.Services.AddScoped<IReviewService, ReviewService>();
builder.Services.AddScoped<IReviewPromptService, ReviewPromptService>();
builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IAchievementService, AchievementService>();
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
builder.Services.AddScoped<IMicrosoftAuthClient, MicrosoftAuthClient>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
builder.Services.AddSingleton<ReviewAnalysisQueue>();
builder.Services.AddSingleton<IReviewAnalysisQueue>(sp => sp.GetRequiredService<ReviewAnalysisQueue>());
builder.Services.AddTransient<NotificationJob>();
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
builder.Services.AddOptions<ReviewAnalysisOptions>()
.Bind(builder.Configuration.GetSection(ReviewAnalysisOptions.SectionName))
.Validate(options => options.MaxConcurrentProcessing >= 1,
"Llm:ReviewAnalysis:MaxConcurrentProcessing must be greater than or equal to 1.")
.ValidateOnStart();
builder.Services.AddQuartz();
if (!isOpenApiGeneration)
{
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
}
if (builder.Environment.IsDevelopment() && !isOpenApiGeneration)
{
builder.Services.AddQuartzDashboard(options =>
{
options.ReadOnly = true;
});
}
// --- HTTP Clients ---
builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
@@ -184,15 +92,11 @@ builder.Services.AddHttpClient<ILlmClient, LlmClient>(client =>
builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
{
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 ---
if (!isOpenApiGeneration)
{
builder.Services.AddHostedService<ReviewAnalysisWorker>();
builder.Services.AddHostedService<AchievementCatalogHostedService>();
}
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
// --- Controllers ---
builder.Services.AddControllers()
@@ -210,16 +114,9 @@ builder.Services.AddSwaggerGen(options =>
{
Title = "UniVerse API",
Version = "v1",
Description =
"REST API веб-платформы UniVerse.\n\n" +
"Аутентификация: JWT Bearer (получить через `POST /api/v1/auth/login/microsoft` или `POST /api/v1/auth/login/dev` в Development).",
Contact = new OpenApiContact
{
Name = "UniVerse Dev"
}
Description = "University schedule, reviews, and gamification platform"
});
// Bearer security scheme definition (used per-endpoint by AuthorizeOperationFilter)
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
@@ -227,74 +124,34 @@ builder.Services.AddSwaggerGen(options =>
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Введите JWT access token, полученный из `/api/v1/auth/login/microsoft`.\n\nПример: `eyJhbGci...`"
Description = "Enter your JWT token"
});
// Include XML doc comments generated from controller /// summaries
var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
options.IncludeXmlComments(xmlPath);
// Per-endpoint security requirement + role documentation (replaces global AddSecurityRequirement)
options.OperationFilter<AuthorizeOperationFilter>();
options.AddSecurityRequirement(doc =>
{
var bearerSchemeRef = new OpenApiSecuritySchemeReference("Bearer", doc, externalResource: null);
return new OpenApiSecurityRequirement
{
[bearerSchemeRef] = new List<string>()
};
});
});
var app = builder.Build();
if (useAspire)
{
app.MapDefaultEndpoints();
}
// --- Middleware Pipeline ---
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseStaticFiles();
app.UseSwagger(c =>
{
c.RouteTemplate = "api/docs/{documentName}/swagger.json";
});
app.UseSwaggerUI(c =>
{
c.RoutePrefix = "api/docs";
c.SwaggerEndpoint("v1/swagger.json", "UniVerse API v1");
});
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "UniVerse API v1"));
}
app.UseCors();
app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
app.UseHttpMetrics();
if (app.Environment.IsDevelopment())
{
app.UseAntiforgery();
app.MapQuartzDashboard();
}
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();
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}";
}
@@ -4,8 +4,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "api/docs",
"launchBrowser": false,
"applicationUrl": "http://localhost:5019",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
+5 -25
View File
@@ -7,36 +7,26 @@
<RootNamespace>UniVerse.Api</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
<OpenApiDocumentsDirectory>$(BaseIntermediateOutputPath)openapi</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<!-- Suppress warnings for public members without XML docs -->
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<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.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Identity.Web" Version="4.9.0" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="4.9.0" />
<PackageReference Include="Quartz.Dashboard" Version="3.18.1" />
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\UniVerse.Application\UniVerse.Application.csproj" />
<ProjectReference Include="..\UniVerse.Infrastructure\UniVerse.Infrastructure.csproj" />
<ProjectReference Include="..\UniVerse.ServiceDefaults\UniVerse.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
@@ -45,14 +35,4 @@
</Content>
</ItemGroup>
<Target
Name="CopyGeneratedOpenApiDocument"
AfterTargets="Build"
Condition="Exists('$(OpenApiDocumentsDirectory)/openapi.json')">
<Copy
SourceFiles="$(OpenApiDocumentsDirectory)/openapi.json"
DestinationFiles="$(MSBuildProjectDirectory)/openapi.json"
SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -4,23 +4,5 @@
"Default": "Information",
"Microsoft.AspNetCore": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=db;Port=5444;Database=universe;Username=universe;Password=pass"
},
"Jwt": {
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
"Issuer": "UniVerse",
"Audience": "UniVerse",
"AccessTokenExpirationMinutes": "30",
"RefreshTokenExpirationDays": "30"
},
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "sfedu.ru",
"ClientId": "",
"ClientSecret": "",
"Domain": "sfedu.onmicrosoft.com",
"CallbackPath": "/signin-oidc"
}
}
+17 -24
View File
@@ -7,48 +7,41 @@
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=universe;Username=postgres;Password=postgres"
},
"Jwt": {
"Secret": "default-dev-secret-key-change-in-production-32chars!!",
"Issuer": "UniVerse",
"Audience": "UniVerse",
"AccessTokenExpirationMinutes": "30",
"RefreshTokenExpirationDays": "30"
},
"Cors": {
"Origins": [
"http://localhost:5173",
"http://localhost:3000"
]
},
"RateLimiting": {
"PermitLimit": 600,
"WindowSeconds": 60,
"QueueLimit": 100
},
"Llm": {
"BaseUrl": "https://api.openai.com/v1/",
"ApiKey": "",
"Model": "gpt-4o-mini",
"ReviewAnalysis": {
"MaxConcurrentProcessing": 1
}
"Model": "gpt-4o-mini"
},
"ModeusApi": {
"BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": "",
"TimeoutSeconds": 180
"ApiKey": ""
},
"Gamification": {
"XpThresholds": [0, 100, 300, 600, 1000, 1500, 2500, 4000]
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Information",
"System": "Information"
"Microsoft": "Warning",
"System": "Warning"
}
}
},
"Email": {
"Smtp": {
"Host": "",
"Port": 587,
"EnableSsl": true,
"UserName": "",
"Password": "",
"FromAddress": "no-reply@universe.local",
"FromName": "UniVerse"
}
}
}
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
var builder = DistributedApplication.CreateBuilder(args);
var api = builder
.AddProject<Projects.UniVerse_Api>("universe-api")
.WithEnvironment("Aspire__Enabled", "true");
// Запуск фронтенда (Vue + Vite) в dev-режиме вместе.
// Требования: установлен pnpm (или включён corepack), зависимости фронта установлены.
builder
.AddExecutable("universe-frontend", "pnpm", workingDirectory: "../../frontend")
.WithArgs("run", "dev:aspire")
.WithHttpEndpoint(targetPort: 5173, port: 5173, name: "http", isProxied: false)
// Используется в vite.config.ts для server.proxy['/api'].target
.WithEnvironment("VITE_API_PROXY_TARGET", api.GetEndpoint("http"));
builder.Build().Run();
@@ -1,48 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://127.0.0.1:17156;http://127.0.0.1:15060",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"ALL_PROXY": "",
"http_proxy": "",
"https_proxy": "",
"all_proxy": "",
"NO_PROXY": "localhost,127.0.0.1,::1",
"no_proxy": "localhost,127.0.0.1,::1",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://127.0.0.1:21010",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://127.0.0.1:23046",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://127.0.0.1:22274"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://127.0.0.1:15060",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"ALL_PROXY": "",
"http_proxy": "",
"https_proxy": "",
"all_proxy": "",
"NO_PROXY": "localhost,127.0.0.1,::1",
"no_proxy": "localhost,127.0.0.1,::1",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://127.0.0.1:19138",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://127.0.0.1:18238",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://127.0.0.1:20274"
}
}
}
}
@@ -1,19 +0,0 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.4">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>fb90d29a-6c48-471b-b19f-d2f431a5ef38</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\UniVerse.Api\UniVerse.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
</ItemGroup>
</Project>
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -1,5 +0,0 @@
{
"appHost": {
"path": "UniVerse.AppHost.csproj"
}
}
@@ -5,8 +5,8 @@ namespace UniVerse.Application.DTOs.Auth;
public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User);
public record AuthResult(AuthResponse Response, string RefreshToken);
public record UserAuthDto(int Id, string Email, string? DisplayName, IReadOnlyList<UserRole> Roles);
public record UserAuthDto(int Id, string Email, string? DisplayName, UserRole Role);
public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null);
public record LoginMicrosoftRequest(string AuthorizationCode);
public record DevLoginRequest(string Email, string? DisplayName = null, IReadOnlyList<UserRole>? Roles = null);
public record DevLoginRequest(string Email, string? DisplayName = null, UserRole Role = UserRole.Student);
@@ -11,8 +11,3 @@ public record CoinTransactionDto(
string? Description,
DateTime CreatedAt
);
public record LevelProgressDto(
int CurrentLevelXp,
int? NextLevelXp
);
@@ -19,8 +19,7 @@ public record LectureDto(
int MaxEnrollments,
int EnrollmentsCount,
string? OnlineUrl,
DateTime CreatedAt,
bool IsEnrolled = false
DateTime CreatedAt
);
public record LectureDetailDto(
@@ -1,42 +0,0 @@
namespace UniVerse.Application.DTOs.Notifications;
public static class NotificationChannels
{
public const string Email = "email";
}
public record NotificationMessage(
string Channel,
string Recipient,
string Subject,
string Body,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record SendNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduleNotificationRequest(
string Channel,
string Recipient,
string Subject,
string Body,
DateTimeOffset SendAt,
string? RecipientName = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public record ScheduledNotificationResponse(string JobId, DateTimeOffset SendAt);
public record UserNotificationDto(
int Id,
string Type,
string Title,
string Body,
bool IsRead,
DateTime CreatedAt
);
@@ -15,20 +15,9 @@ public record ReviewDto(
double? QualityScore,
bool? IsInformative,
string[]? LlmTags,
string? LlmRawOutput,
DateTime CreatedAt
);
public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text);
public record UpdateReviewRequest(ReviewRating Rating, string? Text);
public record ReviewFilterRequest(
ReviewLlmStatus? LlmStatus,
int Page = 1,
int PageSize = 20
);
public record ReviewPromptDto(string Prompt, DateTime? UpdatedAt);
public record UpdateReviewPromptRequest(string Prompt);
@@ -1,27 +1,13 @@
namespace UniVerse.Application.DTOs.Sync;
public record SyncScheduleRequest(
IReadOnlyList<string>? SpecialtyCode,
string? SpecialtyCode,
DateTime? TimeMin,
DateTime? TimeMax,
IReadOnlyList<string>? TypeId,
int? Size = null,
IReadOnlyList<string>? RoomId = null,
IReadOnlyList<string>? AttendeePersonId = null,
IReadOnlyList<string>? CourseUnitRealizationId = null,
IReadOnlyList<string>? CycleRealizationId = null,
IReadOnlyList<int>? LearningStartYear = null,
IReadOnlyList<string>? ProfileName = null,
IReadOnlyList<string>? CurriculumId = null
string? TypeId
);
public record SyncResultDto(
int Created,
int Updated,
int Skipped,
string? Error,
IReadOnlyList<string>? Details = null
);
public record SyncResultDto(int Created, int Updated, int Skipped, string? Error);
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
@@ -7,7 +7,7 @@ public record UserDto(
string Email,
string? DisplayName,
string? AvatarUrl,
IReadOnlyList<UserRole> Roles,
UserRole Role,
bool IsActive,
int Xp,
int Coins,
@@ -15,18 +15,6 @@ public record UserDto(
DateTime CreatedAt
);
public record CurrentUserDto(
int Id,
string Email,
string? DisplayName,
string? AvatarUrl,
IReadOnlyList<UserRole> Roles,
int Xp,
int Coins,
int Level,
DateTime CreatedAt
);
public record UserStatsDto(
int TotalLectures,
int AttendedLectures,
@@ -34,25 +22,9 @@ public record UserStatsDto(
int Xp,
int Coins,
int Level,
int AchievementsCount,
int CurrentLevelXp,
int? NextLevelXp,
int ActiveEnrollments,
int EnrollmentSlotLimit,
IReadOnlyList<EnrollmentSlotRuleDto> EnrollmentSlotRules
int AchievementsCount
);
public record AdminDashboardStatsDto(
int UsersCount,
int LecturesCount,
int EnrollmentsCount,
int PendingReviewsCount
);
public record EnrollmentSlotRuleDto(int Level, int Slots);
public record CalendarSubscriptionDto(string FeedUrl);
public record UpdateUserRequest(
string? DisplayName,
string? AvatarUrl
@@ -5,9 +5,9 @@ namespace UniVerse.Application.Interfaces;
public interface IAuthService
{
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null, string? ipAddress = null);
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode);
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
Task<AuthResult> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(string refreshToken);
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
Task<UserDto> GetCurrentUserAsync(int userId);
}
@@ -10,8 +10,7 @@ public interface IGamificationService
Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
int? reviewId = null, int? achievementId = null, string? description = null);
Task CheckAndAwardAchievementsAsync(int userId);
Task<int> CalculateLevelAsync(int xp);
Task<LevelProgressDto> GetLevelProgressAsync(int xp);
int CalculateLevel(int xp);
Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId);
Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination);
}
@@ -5,13 +5,13 @@ namespace UniVerse.Application.Interfaces;
public interface ILectureService
{
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter, int? currentUserId = null);
Task<PagedResult<LectureDto>> GetAllAsync(LectureFilterRequest filter);
Task<LectureDetailDto> GetByIdAsync(int id, int? currentUserId = null);
Task<LectureDto> CreateAsync(CreateLectureRequest request);
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request, int currentUserId, bool isAdmin = false);
Task<LectureDto> UpdateAsync(int id, UpdateLectureRequest request);
Task DeleteAsync(int id);
Task EnrollAsync(int lectureId, int userId);
Task UnenrollAsync(int lectureId, int userId);
Task MarkAttendanceAsync(int lectureId, int userId, bool attended, int currentUserId, bool isAdmin = false);
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination, int currentUserId, bool isAdmin = false);
Task MarkAttendanceAsync(int lectureId, int userId, bool attended);
Task<PagedResult<EnrollmentDto>> GetEnrollmentsAsync(int lectureId, PaginationRequest pagination);
}
@@ -3,4 +3,5 @@ namespace UniVerse.Application.Interfaces;
public interface ILlmAnalysisService
{
Task AnalyzeReviewAsync(int reviewId);
Task ProcessPendingReviewsAsync();
}
@@ -4,8 +4,7 @@ public record LlmReviewAnalysis(
double QualityScore,
string Sentiment,
string[] Tags,
bool IsInformative,
string RawOutput
bool IsInformative
);
public interface ILlmClient
@@ -1,11 +0,0 @@
namespace UniVerse.Application.Interfaces;
public interface IMicrosoftAuthClient
{
Task<MicrosoftTokenResult> ExchangeAuthorizationCodeAsync(
string authorizationCode,
string redirectUri,
CancellationToken cancellationToken = default);
}
public record MicrosoftTokenResult(string IdToken);
@@ -1,9 +0,0 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationProvider
{
string Channel { get; }
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
}
@@ -1,14 +0,0 @@
using UniVerse.Application.DTOs.Notifications;
namespace UniVerse.Application.Interfaces;
public interface INotificationScheduler
{
Task<ScheduledNotificationResponse> ScheduleAsync(
NotificationMessage message,
DateTimeOffset sendAt,
string? jobId = null,
CancellationToken cancellationToken = default);
Task CancelAsync(string jobId, CancellationToken cancellationToken = default);
}
@@ -1,13 +0,0 @@
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.DTOs.Common;
namespace UniVerse.Application.Interfaces;
public interface INotificationService
{
Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default);
Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default);
Task<UserNotificationDto> CreateUserNotificationAsync(int userId, string type, string title, string body, CancellationToken cancellationToken = default);
Task<PagedResult<UserNotificationDto>> GetUserNotificationsAsync(int userId, PaginationRequest pagination, CancellationToken cancellationToken = default);
Task MarkAllReadAsync(int userId, CancellationToken cancellationToken = default);
}
@@ -1,6 +0,0 @@
namespace UniVerse.Application.Interfaces;
public interface IReviewAnalysisQueue
{
Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default);
}
@@ -1,9 +0,0 @@
using UniVerse.Application.DTOs.Reviews;
namespace UniVerse.Application.Interfaces;
public interface IReviewPromptService
{
Task<ReviewPromptDto> GetAsync();
Task<ReviewPromptDto> UpdateAsync(UpdateReviewPromptRequest request);
}
@@ -9,8 +9,8 @@ public interface IReviewService
Task<ReviewDto> GetByIdAsync(int id);
Task<ReviewDto> UpdateAsync(int id, int userId, UpdateReviewRequest request);
Task DeleteAsync(int id, int userId, bool isAdmin = false);
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination, int? currentUserId = null, bool isAdmin = false);
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
Task ReanalyzeAsync(int id);
}
@@ -1,5 +1,4 @@
using UniVerse.Application.DTOs.Sync;
using System.Text.Json.Serialization;
namespace UniVerse.Application.Interfaces;
@@ -16,105 +15,11 @@ public interface IModeusApiClient
Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request);
Task<ModeusRoomsResponse> SearchRoomsAsync();
Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname);
Task<string?> GetSubIdByFullNameAsync(string fullname, CancellationToken cancellationToken = default);
}
// Modeus API response models
public class ModeusEvent
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string? NameShort { get; init; }
public string? Description { get; init; }
public string? TypeId { get; init; }
public DateTime StartsAt { get; init; }
public DateTime EndsAt { get; init; }
public ModeusIctisStats? IctisStats { get; init; }
[JsonPropertyName("_links")]
public ModeusEventLinks? Links { get; init; }
}
public record ModeusIctisStats(int? StudentCount, int? TeacherCount);
public class ModeusEventLinks
{
[JsonPropertyName("course-unit-realization")]
public ModeusHrefLink? CourseUnitRealization { get; init; }
}
public class ModeusEventsResponse
{
[JsonPropertyName("_embedded")]
public ModeusEventsEmbedded? Embedded { get; init; }
public List<ModeusEvent>? Events { get; init; }
public ModeusPage? Page { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusEvent> EventItems => Embedded?.Events ?? Events ?? [];
}
public class ModeusEventsEmbedded
{
public List<ModeusEvent>? Events { get; init; }
[JsonPropertyName("course-unit-realizations")]
public List<ModeusCourseUnitRealization>? CourseUnitRealizations { get; init; }
[JsonPropertyName("event-rooms")]
public List<ModeusEventRoom>? EventRooms { get; init; }
[JsonPropertyName("event-teams")]
public List<ModeusEventTeam>? EventTeams { get; init; }
[JsonPropertyName("event-attendees")]
public List<ModeusEventAttendee>? EventAttendees { get; init; }
public List<ModeusPerson>? Persons { get; init; }
public List<ModeusRoom>? Rooms { get; init; }
}
public record ModeusHrefLink(string? Href);
public record ModeusCourseUnitRealization(string Id, string Name, string? NameShort);
public class ModeusEventRoom
{
public string Id { get; init; } = string.Empty;
[JsonPropertyName("_links")]
public ModeusEventRoomLinks? Links { get; init; }
}
public class ModeusEventRoomLinks
{
public ModeusHrefLink? Event { get; init; }
public ModeusHrefLink? Room { get; init; }
}
public record ModeusEventTeam(string EventId, int? Size);
public class ModeusEventAttendee
{
public string Id { get; init; } = string.Empty;
public string? RoleId { get; init; }
public string? RoleName { get; init; }
[JsonPropertyName("_links")]
public ModeusEventAttendeeLinks? Links { get; init; }
}
public class ModeusEventAttendeeLinks
{
public ModeusHrefLink? Event { get; init; }
public ModeusHrefLink? Person { get; init; }
}
public record ModeusPerson(string Id, string? LastName, string? FirstName, string? MiddleName, string? FullName);
public record ModeusBuilding(string? Id, string? Name, string? NameShort, string? Address);
public record ModeusRoom(string Id, string Name, string? NameShort, ModeusBuilding? Building, int? TotalCapacity, int? WorkingCapacity);
public record ModeusRoomsEmbedded(List<ModeusRoom>? Rooms);
public record ModeusPage(int Size, int TotalElements, int TotalPages, int Number);
public class ModeusRoomsResponse
{
[JsonPropertyName("_embedded")]
public ModeusRoomsEmbedded? Embedded { get; init; }
public ModeusPage? Page { get; init; }
public List<ModeusRoom>? Rooms { get; init; }
[JsonIgnore]
public IReadOnlyList<ModeusRoom> RoomItems => Embedded?.Rooms ?? Rooms ?? [];
}
public record ModeusEvent(string Id, string Name, DateTime StartsAt, DateTime EndsAt, string? RoomId, string? TeacherId, string? TypeId);
public record ModeusEventsResponse(List<ModeusEvent> Events);
public record ModeusRoom(string Id, string Name, string? Building);
public record ModeusRoomsResponse(List<ModeusRoom> Rooms);
public record ModeusEmployee(string? Id, string FullName, string? Department);
@@ -1,5 +1,4 @@
using UniVerse.Application.DTOs.Common;
using UniVerse.Application.DTOs.Lectures;
using UniVerse.Application.DTOs.Users;
using UniVerse.Domain.Enums;
@@ -10,13 +9,7 @@ public interface IUserService
Task<UserDto> GetByIdAsync(int id);
Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request);
Task<UserStatsDto> GetStatsAsync(int id);
Task<AdminDashboardStatsDto> GetAdminDashboardStatsAsync();
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 SetRolesAsync(int id, IReadOnlyCollection<UserRole> roles);
Task SetRoleAsync(int id, UserRole role);
Task SetActiveAsync(int id, bool isActive);
}
@@ -13,22 +13,14 @@ namespace UniVerse.Application.Mappings;
public static class MappingExtensions
{
private static int OccupiedSeatsCount(this Lecture lecture) =>
Math.Max(0, lecture.MandatoryAttendeesCount) + lecture.Enrollments.Count;
// --- User ---
public static UserDto ToDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
);
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
user.Role, user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
);
public static UserAuthDto ToAuthDto(this User user) => new(
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
user.Id, user.Email, user.DisplayName, user.Role
);
// --- Tag ---
@@ -54,14 +46,14 @@ public static class MappingExtensions
);
// --- Lecture ---
public static LectureDto ToDto(this Lecture lecture, bool isEnrolled = false) => new(
public static LectureDto ToDto(this Lecture lecture) => new(
lecture.Id, lecture.CourseId, lecture.Course?.Name ?? "",
lecture.TeacherId, lecture.Teacher?.DisplayName,
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt
);
public static LectureDetailDto ToDetailDto(this Lecture lecture, bool isEnrolled) => new(
@@ -70,7 +62,7 @@ public static class MappingExtensions
lecture.LocationId, lecture.Location?.Name,
lecture.Title, lecture.Description, lecture.Format,
lecture.StartsAt, lecture.EndsAt, lecture.IsOpen,
lecture.MaxEnrollments, lecture.OccupiedSeatsCount(),
lecture.MaxEnrollments, lecture.Enrollments.Count,
lecture.OnlineUrl, lecture.CreatedAt, isEnrolled
);
@@ -87,7 +79,7 @@ public static class MappingExtensions
review.UserId, review.User?.DisplayName,
review.Rating, review.Text, review.LlmStatus,
review.Sentiment, review.QualityScore, review.IsInformative,
review.LlmTags, review.LlmRawOutput, review.CreatedAt
review.LlmTags, review.CreatedAt
);
// --- Achievement ---
@@ -1,53 +0,0 @@
namespace UniVerse.Application.Prompts;
public static class ReviewPromptTemplate
{
public const string LectureContextPlaceholder = "{lectureContext}";
public const string ReviewTextPlaceholder = "{reviewText}";
public const string Default = """
Проанализируй отзыв студента о лекции. Главная задача - определить, насколько отзыв информативен и полезен для аналитики качества лекции и обратной связи преподавателю.
Верни только валидный JSON-объект без Markdown, пояснений и дополнительного текста:
{
"quality_score": 0.0,
"sentiment": "Нейтральный",
"tags": [],
"is_informative": false
}
Правила оценки:
- quality_score: число от 0 до 1. Оценивай содержательность, конкретику, аргументацию, конструктивность и развернутость отзыва, а не оценку лекции как таковой.
- is_informative: true, если отзыв содержит конкретные наблюдения о лекции, преподавании, структуре, материалах, темпе, сложности, практике, организации или полезности. false для односложных, шаблонных, эмоциональных без конкретики или нерелевантных отзывов.
- sentiment: строго одно из значений "Положительный", "Нейтральный", "Отрицательный".
- tags: массив коротких тематических тегов на русском языке. Используй 1-5 тегов, если они подходят; для неинформативного отзыва можно вернуть пустой массив.
Базовые теги:
- "структура лекции"
- "понятность объяснения"
- "темп"
- "сложность"
- "практические примеры"
- "материалы"
- "актуальность темы"
- "вовлеченность"
- "организация"
- "технические проблемы"
- "польза для обучения"
- "неинформативный отзыв"
Можно добавлять новые теги, если они точнее отражают содержание отзыва. Не добавляй теги, которых нет в тексте отзыва или контексте лекции.
Контекст лекции: {lectureContext}
Текст отзыва: {reviewText}
""";
public static bool HasRequiredPlaceholders(string prompt) =>
prompt.Contains(LectureContextPlaceholder, StringComparison.Ordinal) &&
prompt.Contains(ReviewTextPlaceholder, StringComparison.Ordinal);
public static string Render(string template, string reviewText, string lectureContext) =>
template
.Replace(LectureContextPlaceholder, lectureContext)
.Replace(ReviewTextPlaceholder, reviewText);
}
@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
@@ -15,7 +15,6 @@ public class Lecture
public DateTime EndsAt { get; set; }
public bool IsOpen { get; set; } = true;
public int MaxEnrollments { get; set; }
public int MandatoryAttendeesCount { get; set; }
public string? ExternalId { get; set; }
public string? OnlineUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -1,7 +0,0 @@
namespace UniVerse.Domain.Entities;
public class LevelThreshold
{
public int Level { get; set; }
public int RequiredXp { get; set; }
}
@@ -14,7 +14,6 @@ public class Review
public double? QualityScore { get; set; }
public bool? IsInformative { get; set; }
public string[]? LlmTags { get; set; }
public string? LlmRawOutput { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -1,11 +0,0 @@
namespace UniVerse.Domain.Entities;
public class ReviewPromptSetting
{
public const int SingletonId = 1;
public int Id { get; set; } = SingletonId;
public string Prompt { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+1 -2
View File
@@ -8,6 +8,7 @@ public class User
public string Email { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? AvatarUrl { get; set; }
public UserRole Role { get; set; } = UserRole.Student;
public bool IsActive { get; set; } = true;
public string? MicrosoftId { get; set; }
public int Xp { get; set; }
@@ -18,11 +19,9 @@ public class User
// Navigation properties
public StudentProfile? StudentProfile { get; set; }
public TeacherProfile? TeacherProfile { get; set; }
public ICollection<UserRoleAssignment> Roles { get; set; } = new List<UserRoleAssignment>();
public ICollection<LectureEnrollment> Enrollments { get; set; } = new List<LectureEnrollment>();
public ICollection<Review> Reviews { get; set; } = new List<Review>();
public ICollection<UserAchievement> UserAchievements { get; set; } = new List<UserAchievement>();
public ICollection<CoinTransaction> CoinTransactions { get; set; } = new List<CoinTransaction>();
public ICollection<UserNotification> Notifications { get; set; } = new List<UserNotification>();
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
@@ -1,14 +0,0 @@
namespace UniVerse.Domain.Entities;
public class UserNotification
{
public int Id { get; set; }
public int UserId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}
@@ -1,11 +0,0 @@
using UniVerse.Domain.Enums;
namespace UniVerse.Domain.Entities;
public class UserRoleAssignment
{
public int UserId { get; set; }
public UserRole Role { get; set; }
public User User { get; set; } = null!;
}
@@ -1,8 +0,0 @@
namespace UniVerse.Domain.Exceptions;
public class BadRequestException : Exception
{
public BadRequestException(string message) : base(message)
{
}
}
@@ -1,21 +0,0 @@
namespace UniVerse.Domain.Services;
public static class EnrollmentSlotPolicy
{
private static readonly IReadOnlyList<EnrollmentSlotRule> SlotRules =
[
new(1, 3),
new(3, 5),
new(4, 7)
];
public static IReadOnlyList<EnrollmentSlotRule> Rules => SlotRules;
public static int GetLimitForLevel(int level) =>
SlotRules
.Where(rule => rule.Level <= level)
.OrderBy(rule => rule.Level)
.LastOrDefault()?.Slots ?? SlotRules[0].Slots;
}
public sealed record EnrollmentSlotRule(int Level, int Slots);
@@ -1,92 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data;
public static class AchievementCatalogSeeder
{
private static readonly IReadOnlyList<AchievementSeed> Catalog =
[
new(1001, "Добро пожаловать в UniVerse", "Совершить первое действие: записаться на лекцию, оставить отзыв или посетить занятие.", "sparkles", 10, "first_activity:1"),
new(1002, "Первый шаг", "Посетить первую открытую лекцию.", "book-2", 10, "lectures_attended:1"),
new(1003, "Вошел во вкус", "Посетить 3 открытые лекции.", "books", 20, "lectures_attended:3"),
new(1004, "Постоянный слушатель", "Посетить 5 открытых лекций.", "calendar-event", 35, "lectures_attended:5"),
new(1005, "Академический марафон", "Посетить 10 открытых лекций.", "stopwatch", 60, "lectures_attended:10"),
new(1006, "Грандмастер лекций", "Посетить 25 открытых лекций.", "trophy", 120, "lectures_attended:25"),
new(1007, "Первый отзыв", "Оставить первый отзыв о посещенной лекции.", "message-circle", 10, "reviews_written:1"),
new(1008, "Голос аудитории", "Оставить 3 отзыва о лекциях.", "thumb-up", 25, "reviews_written:3"),
new(1009, "Рецензент", "Оставить 10 отзывов о лекциях.", "clipboard-list", 70, "reviews_written:10"),
new(1010, "Голос перемен", "Оставить 25 отзывов о лекциях.", "chart-line", 150, "reviews_written:25"),
new(1011, "Смелый выбор", "Записаться на первую открытую лекцию.", "calendar", 5, "lectures_registered:1"),
new(1012, "План на неделю", "Иметь 3 активные записи на будущие лекции.", "calendar-event", 15, "active_registrations:3"),
new(1013, "Полный календарь", "Иметь 5 активных записей на будущие лекции.", "alarm", 30, "active_registrations:5"),
new(1014, "Серия интереса", "Посещать открытые лекции 3 недели подряд.", "star", 50, "attendance_streak_weeks:3"),
new(1015, "Учебный месяц", "Посещать открытые лекции 4 недели подряд.", "sparkles", 80, "attendance_streak_weeks:4"),
new(1016, "Без пропусков", "Посетить 5 лекций, на которые была оформлена запись.", "circle-check", 40, "attended_registered:5"),
new(1017, "Надежный участник", "Посетить 10 лекций, на которые была оформлена запись.", "shield", 75, "attended_registered:10"),
new(1018, "Капитал знаний", "Получить 500 монет за активность на платформе.", "coin", 80, "coins_earned:500"),
new(1019, "Новый уровень", "Достигнуть 2 уровня.", "star", 25, "level_reached:2"),
new(1020, "Уверенный рост", "Достигнуть 5 уровня.", "chart-bar", 100, "level_reached:5"),
new(1021, "Профиль заполнен", "Заполнить имя и аватар в профиле.", "user", 10, "profile_completed:1")
];
private static readonly IReadOnlyDictionary<string, string> LegacyConditions = new Dictionary<string, string>
{
["reviews_1"] = "reviews_written:1",
["reviews_5"] = "reviews_written:5",
["reviews_10"] = "reviews_written:10",
["attended_5"] = "lectures_attended:5",
["attended_10"] = "lectures_attended:10"
};
public static async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken = default)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var legacyConditionKeys = LegacyConditions.Keys.ToArray();
var legacyAchievements = await db.Achievements
.Where(a => a.Condition != null && legacyConditionKeys.Contains(a.Condition))
.ToListAsync(cancellationToken);
foreach (var achievement in legacyAchievements)
achievement.Condition = LegacyConditions[achievement.Condition!];
foreach (var seed in Catalog)
{
var achievement = await db.Achievements.FindAsync([seed.Id], cancellationToken);
if (achievement == null)
{
db.Achievements.Add(new Achievement
{
Id = seed.Id,
Name = seed.Name,
Description = seed.Description,
IconUrl = seed.IconUrl,
XpReward = 0,
CoinReward = seed.CoinReward,
Condition = seed.Condition
});
continue;
}
achievement.Name = seed.Name;
achievement.Description = seed.Description;
achievement.IconUrl = seed.IconUrl;
achievement.XpReward = 0;
achievement.CoinReward = seed.CoinReward;
achievement.Condition = seed.Condition;
}
await db.SaveChangesAsync(cancellationToken);
}
private sealed record AchievementSeed(
int Id,
string Name,
string Description,
string IconUrl,
int CoinReward,
string Condition);
}
@@ -10,7 +10,6 @@ public class AppDbContext : DbContext
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; } = null!;
public DbSet<UserRoleAssignment> UserRoles { get; set; } = null!;
public DbSet<StudentProfile> StudentProfiles { get; set; } = null!;
public DbSet<TeacherProfile> TeacherProfiles { get; set; } = null!;
public DbSet<Course> Courses { get; set; } = null!;
@@ -20,12 +19,9 @@ public class AppDbContext : DbContext
public DbSet<CourseTag> CourseTags { get; set; } = null!;
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
public DbSet<Review> Reviews { get; set; } = null!;
public DbSet<ReviewPromptSetting> ReviewPromptSettings { get; set; } = null!;
public DbSet<Achievement> Achievements { get; set; } = null!;
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!;
public DbSet<LevelThreshold> LevelThresholds { get; set; } = null!;
public DbSet<UserNotification> UserNotifications { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
static AppDbContext()
@@ -22,7 +22,6 @@ public class LectureConfiguration : IEntityTypeConfiguration<Lecture>
builder.Property(l => l.EndsAt).HasColumnName("ends_at");
builder.Property(l => l.IsOpen).HasColumnName("is_open").HasDefaultValue(true);
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.OnlineUrl).HasColumnName("online_url").HasMaxLength(500);
builder.Property(l => l.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
@@ -1,33 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class LevelThresholdConfiguration : IEntityTypeConfiguration<LevelThreshold>
{
public void Configure(EntityTypeBuilder<LevelThreshold> builder)
{
builder.ToTable("level_thresholds", table =>
{
table.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
table.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
builder.HasKey(t => t.Level);
builder.Property(t => t.Level).HasColumnName("level").ValueGeneratedNever();
builder.Property(t => t.RequiredXp).HasColumnName("required_xp").IsRequired();
builder.HasIndex(t => t.RequiredXp).IsUnique();
builder.HasData(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 },
new LevelThreshold { Level = 4, RequiredXp = 600 },
new LevelThreshold { Level = 5, RequiredXp = 1000 },
new LevelThreshold { Level = 6, RequiredXp = 1500 },
new LevelThreshold { Level = 7, RequiredXp = 2500 },
new LevelThreshold { Level = 8, RequiredXp = 4000 }
);
}
}
@@ -21,7 +21,6 @@ public class ReviewConfiguration : IEntityTypeConfiguration<Review>
builder.Property(r => r.QualityScore).HasColumnName("quality_score");
builder.Property(r => r.IsInformative).HasColumnName("is_informative");
builder.Property(r => r.LlmTags).HasColumnName("llm_tags");
builder.Property(r => r.LlmRawOutput).HasColumnName("llm_raw_output");
builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");

Some files were not shown because too many files have changed in this diff Show More